feat(vpn): VPN on-demand Tailscale + agent studioE5 standalone

- Agent studioE5 standalone en Go (console + systray)
- VPN on-demand via tailscaled + tailscale up (authkey Headscale)
- Resolver/serveur dans le tailnet studioe5
- Caddy on-demand TLS pour les instances
- Nouveaux endpoints serveur /api/internal/send-to-node
- Suppression des anciens binaires edubox-agent
- Suivi dans SUIVI_VPN_ONDEMAND.md
This commit is contained in:
EduBox Dev
2026-06-23 09:48:00 +00:00
parent dd49993157
commit 124543d658
40 changed files with 1303 additions and 485 deletions
+29 -16
View File
@@ -2,33 +2,46 @@
set -e
VERSION="0.3.0"
APP_NAME="studioE5"
BIN_NAME="studioE5-agent"
LDFLAGS="-X main.version=${VERSION}"
echo "Building EduBox Agent v${VERSION}..."
# On Windows, build a GUI binary so no console window opens on double-click.
WIN_LDFLAGS="${LDFLAGS} -H windowsgui"
echo "Building ${APP_NAME} Agent v${VERSION}..."
export PATH=$PATH:/usr/local/go/bin
GOOS=windows GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o edubox-agent.exe .
echo " edubox-agent.exe (Windows amd64)"
cp edubox-agent.exe "edubox-agent-v${VERSION}.exe"
echo " edubox-agent-v${VERSION}.exe (Windows amd64)"
GOOS=windows GOARCH=amd64 go build -ldflags "${WIN_LDFLAGS}" -o ${BIN_NAME}.exe .
echo " ${BIN_NAME}.exe (Windows amd64)"
cp ${BIN_NAME}.exe "${BIN_NAME}-v${VERSION}.exe"
echo " ${BIN_NAME}-v${VERSION}.exe (Windows amd64)"
GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o edubox-agent .
echo " edubox-agent (Linux amd64)"
cp edubox-agent "edubox-agent-v${VERSION}"
echo " edubox-agent-v${VERSION} (Linux amd64)"
GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o ${BIN_NAME} .
echo " ${BIN_NAME} (Linux amd64)"
cp ${BIN_NAME} "${BIN_NAME}-v${VERSION}"
echo " ${BIN_NAME}-v${VERSION} (Linux amd64)"
GOOS=darwin GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o edubox-agent-mac .
echo " edubox-agent-mac (macOS amd64)"
cp edubox-agent-mac "edubox-agent-v${VERSION}-mac"
echo " edubox-agent-v${VERSION}-mac (macOS amd64)"
# macOS build requires CGO for the systray menu; skip gracefully if unavailable.
if GOOS=darwin GOARCH=amd64 CGO_ENABLED=1 go build -ldflags "${LDFLAGS}" -o ${BIN_NAME}-mac . 2>/dev/null; then
echo " ${BIN_NAME}-mac (macOS amd64)"
cp ${BIN_NAME}-mac "${BIN_NAME}-v${VERSION}-mac"
echo " ${BIN_NAME}-v${VERSION}-mac (macOS amd64)"
MAC_BUILT=1
else
echo " ${BIN_NAME}-mac (macOS amd64) - skipped, CGO required for systray"
MAC_BUILT=0
fi
# 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"
cp "${BIN_NAME}-v${VERSION}" "${SERVER_PUBLIC}/${BIN_NAME}-v${VERSION}"
cp "${BIN_NAME}-v${VERSION}.exe" "${SERVER_PUBLIC}/${BIN_NAME}-v${VERSION}.exe"
if [ "$MAC_BUILT" = "1" ]; then
cp "${BIN_NAME}-v${VERSION}-mac" "${SERVER_PUBLIC}/${BIN_NAME}-v${VERSION}-mac"
fi
echo " Copied versioned binaries to ${SERVER_PUBLIC}"
fi
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
)
// AgentConfig holds user-editable settings for the agent.
type AgentConfig struct {
Server string `json:"server"`
HeadscaleURL string `json:"headscale_url"`
HeadscaleAuthKey string `json:"headscale_auth_key"`
NodeID string `json:"node_id"`
DataDir string `json:"data_dir"`
}
const configFileName = "studioE5-config.json"
// defaultConfig returns sensible defaults for a first run.
func defaultConfig(dataDir string) *AgentConfig {
return &AgentConfig{
Server: "ws://localhost:3001",
HeadscaleURL: "",
HeadscaleAuthKey: "",
NodeID: defaultNodeID(),
DataDir: dataDir,
}
}
// configPath returns the absolute path to the config file.
func configPath(dataDir string) string {
return filepath.Join(dataDir, configFileName)
}
// loadOrCreateConfig loads the config file. If it does not exist, it creates
// one with default values and returns it (the caller can then open the settings UI).
func loadOrCreateConfig(dataDir string) (*AgentConfig, bool, error) {
cp := configPath(dataDir)
if _, err := os.Stat(cp); err == nil {
data, err := os.ReadFile(cp)
if err != nil {
return nil, false, err
}
var cfg AgentConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, false, err
}
return &cfg, false, nil
}
cfg := defaultConfig(dataDir)
if err := saveConfig(dataDir, cfg); err != nil {
return nil, true, err
}
return cfg, true, nil
}
// saveConfig writes the config file to disk.
func saveConfig(dataDir string, cfg *AgentConfig) error {
cp := configPath(dataDir)
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(cp, data, 0644)
}
+5 -3
View File
@@ -20,18 +20,20 @@ func getContainerEngine() string {
return "docker"
}
func writeCompose(dataDir, instanceID, compose string) error {
func writeCompose(dataDir, instanceID, compose string, port int) error {
dir := instanceDir(dataDir, instanceID)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
// Ensure the EduBox mu-plugin is available and substitute its path
// Ensure the studioE5 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))
compose = strings.ReplaceAll(compose, "{INSTANCE_ID}", instanceID)
compose = strings.ReplaceAll(compose, "{PORT}", fmt.Sprintf("%d", port))
f := filepath.Join(dir, "docker-compose.yml")
return os.WriteFile(f, []byte(compose), 0644)
@@ -115,7 +117,7 @@ fi
}
// stripWordPressHardcodedURLs removes hardcoded WP_HOME/WP_SITEURL defines
// from wp-config.php so the EduBox mu-plugin can compute them from the Host
// from wp-config.php so the studioE5 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 {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
import zlib
import struct
def png_chunk(chunk_type: bytes, data: bytes) -> bytes:
return (
struct.pack(">I", len(data))
+ chunk_type
+ data
+ struct.pack(">I", zlib.crc32(chunk_type + data) & 0xFFFFFFFF)
)
def create_png(width: int, height: int, pixels: list) -> bytes:
"""Create a PNG from a 2D list of (R, G, B) tuples."""
raw = bytearray()
for row in pixels:
raw.append(0) # no filter
for r, g, b in row:
raw.extend((r, g, b))
compressed = zlib.compress(bytes(raw), 9)
ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
ihdr = png_chunk(b"IHDR", ihdr_data)
idat = png_chunk(b"IDAT", compressed)
iend = png_chunk(b"IEND", b"")
return b"\x89PNG\r\n\x1a\n" + ihdr + idat + iend
BLUE = (37, 99, 235)
WHITE = (255, 255, 255)
SIZE = 64
pixels = []
for y in range(SIZE):
row = []
for x in range(SIZE):
dx = x - SIZE // 2
dy = y - SIZE // 2
dist = (dx * dx + dy * dy) ** 0.5
if dist < SIZE * 0.25:
row.append(WHITE)
else:
row.append(BLUE)
pixels.append(row)
with open("icon.png", "wb") as f:
f.write(create_png(SIZE, SIZE, pixels))
print("Generated icon.png")
+1 -41
View File
@@ -3,52 +3,12 @@ module edubox-agent
go 1.26.4
require (
fyne.io/systray v1.12.2
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
tailscale.com v1.100.0
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/creachadair/msync v0.7.1 // indirect
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gaissmai/bart v0.26.1 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/huin/goupnp v1.3.0 // indirect
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/pires/go-proxyproto v0.8.1 // indirect
github.com/safchain/ethtool v0.3.0 // indirect
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd // indirect
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad // indirect
github.com/x448/float16 v0.8.4 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect
)
+2 -224
View File
@@ -1,233 +1,11 @@
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k=
github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ=
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c=
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/creachadair/mds v0.25.9 h1:080Hr8laN2h+l3NeVCGMBpXtIPnl9mz8e4HLraGPqtA=
github.com/creachadair/mds v0.25.9/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs=
github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho=
github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008=
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk=
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ=
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8=
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689 h1:0psnKZ+N2IP43/SZC8SKx6OpFJwLmQb9m9QyV9BC2f8=
github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689/go.mod h1:OGmRfY/9QEK2P5zCRtmqfbCF283xPkU2dvVA4MvbvpI=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I=
github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk=
github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c=
github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89 h1:glgVc1ZYMjwN1Q/ITWeuSQyl029uayagaR2sjsifehc=
github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM=
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA=
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad h1:Ky26FR5yZ5IKEB0xtm5A8xSTb06ImY7kxBFrvgOmJSg=
github.com/tailscale/wireguard-go v0.0.0-20260527010701-b48af7099cad/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA=
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
tailscale.com v1.100.0 h1:nm/M/dEaW9RaRsGUjW2HsSDpsZ60Jwd9k4gNW9tTFiE=
tailscale.com v1.100.0/go.mod h1:DQ9YBy85DpNlSyeU2XRIWzbAu3RsGp/frv+Khg57meE=
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 B

+47 -13
View File
@@ -5,21 +5,22 @@ import (
"log"
"os"
"path/filepath"
"runtime"
"time"
)
// version is injected at build time via -ldflags "-X main.version=X.Y.Z"
var version = "dev"
const AGENT_VERSION = "0.3.0"
const (
AGENT_VERSION = "0.3.0"
APP_NAME = "studioE5"
)
var (
serverAddr = flag.String("server", "ws://localhost:3001", "Adresse WebSocket du serveur")
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")
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")
dataDir = flag.String("data-dir", "./studioE5-data", "Répertoire de données")
uiEnabled = flag.Bool("ui", true, "Activer l'interface locale HTMX")
noTray = flag.Bool("no-tray", false, "Désactiver l'icône dans la barre système (mode console)")
)
func defaultNodeID() string {
@@ -49,19 +50,52 @@ func main() {
log.Fatalf("Cannot create data-dir: %v", err)
}
log.Printf("[EduBox Agent] version=%s node=%s data-dir=%s", AGENT_VERSION, *nodeID, *dataDir)
cfg, created, err := loadOrCreateConfig(*dataDir)
if err != nil {
log.Fatalf("Cannot load config: %v", err)
}
if cfg.Server == "" {
cfg.Server = "ws://localhost:3001"
}
if cfg.NodeID == "" {
cfg.NodeID = defaultNodeID()
}
if cfg.DataDir == "" {
cfg.DataDir = *dataDir
}
if err := saveConfig(*dataDir, cfg); err != nil {
log.Fatalf("Cannot save config: %v", err)
}
log.Printf("[%s Agent] version=%s node=%s data-dir=%s server=%s", APP_NAME, AGENT_VERSION, cfg.NodeID, *dataDir, cfg.Server)
if *uiEnabled {
go startUI(*dataDir, *nodeID, *serverAddr)
go startUI(*dataDir, cfg.NodeID, cfg.Server)
if created {
go openBrowser(uiURL + "#settings")
}
}
go startWebSocket(*serverAddr, *nodeID, *dataDir)
go startWebSocket(cfg.Server, cfg.NodeID, *dataDir, cfg.HeadscaleURL, cfg.HeadscaleAuthKey)
if *headscaleURL != "" && *headscaleAuthKey != "" {
go startTailscaleAndReport(*dataDir, *nodeID, *headscaleURL, *headscaleAuthKey)
shutdownCh := make(chan struct{})
if *noTray {
log.Printf("[%s Agent] running in console mode (no tray)", APP_NAME)
<-shutdownCh
return
}
select {}
// Run tray on its own locked OS thread; keep main blocked so the process
// does not exit when systray is not available (e.g. headless Linux).
go func() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
runTray(APP_NAME, shutdownCh)
}()
<-shutdownCh
}
func startTailscaleAndReport(dataDir, nodeID, headscaleURL, authKey string) {
+90
View File
@@ -0,0 +1,90 @@
package main
import (
_ "embed"
"fmt"
"log"
"os"
"os/exec"
"runtime"
"strings"
"time"
"fyne.io/systray"
)
//go:embed icon.png
var iconBytes []byte
const uiURL = "http://localhost:7070"
func runTray(appName string, shutdownCh chan struct{}) {
systray.Run(func() { onTrayReady(appName, shutdownCh) }, func() { onTrayExit(shutdownCh) })
}
func onTrayReady(appName string, shutdownCh chan struct{}) {
systray.SetIcon(iconBytes)
systray.SetTitle(appName)
systray.SetTooltip(fmt.Sprintf("%s Agent - Cliquez pour ouvrir l'interface", appName))
mOpen := systray.AddMenuItem("Ouvrir l'interface", "Ouvrir l'interface web locale")
mInstances := systray.AddMenuItem("Mes instances", "Afficher les instances")
mSettings := systray.AddMenuItem("Paramètres", "Ouvrir les paramètres")
systray.AddSeparator()
mQuit := systray.AddMenuItem("Quitter", "Arrêter l'agent")
go func() {
for {
select {
case <-mOpen.ClickedCh:
openBrowser(uiURL)
case <-mInstances.ClickedCh:
openBrowser(uiURL + "#instances")
case <-mSettings.ClickedCh:
openBrowser(uiURL + "#settings")
case <-mQuit.ClickedCh:
close(shutdownCh)
systray.Quit()
return
}
}
}()
}
func onTrayExit(shutdownCh chan struct{}) {
log.Printf("Tray exit requested")
// If the user did not already trigger shutdown via the menu, signal it now.
select {
case <-shutdownCh:
default:
close(shutdownCh)
}
// Give other goroutines a moment to clean up, then exit.
time.Sleep(200 * time.Millisecond)
os.Exit(0)
}
func openBrowser(url string) {
var cmd string
var args []string
switch runtime.GOOS {
case "windows":
cmd = "rundll32"
args = []string{"url.dll,FileProtocolHandler", url}
case "darwin":
cmd = "open"
args = []string{url}
default:
cmd = "xdg-open"
args = []string{url}
}
if err := exec.Command(cmd, args...).Start(); err != nil {
log.Printf("Failed to open browser: %v", err)
}
}
func normalizeName(name string) string {
return strings.ReplaceAll(name, " ", "")
}
+139 -65
View File
@@ -2,104 +2,178 @@ package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"os"
"io"
"os/exec"
"path/filepath"
"runtime"
"sync"
"syscall"
"time"
"tailscale.com/tsnet"
)
var globalTSServer *tsnet.Server
var (
tsCmd *exec.Cmd
tsCmdMu sync.Mutex
tsIP string
tsDataDir string
tsSocket string
)
type tailscaleStatus struct {
Self struct {
TailscaleIPs []string `json:"TailscaleIPs"`
} `json:"Self"`
}
func tailscaleBin(name string) string {
// Prefer bundled binaries (tailscale-bin/<os>/tailscaled etc.).
ex, err := os.Executable()
if err == nil {
bundled := filepath.Join(filepath.Dir(ex), "tailscale-bin", runtime.GOOS, name)
if runtime.GOOS == "windows" {
bundled += ".exe"
}
if _, err := os.Stat(bundled); err == nil {
return bundled
}
}
if p, err := exec.LookPath(name); err == nil {
return p
}
return name
}
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)
tsCmdMu.Lock()
defer tsCmdMu.Unlock()
s := &tsnet.Server{
Hostname: nodeID,
Dir: dataDir,
Logf: log.Printf,
if tsCmd != nil {
return tsIP, nil
}
if err := s.Start(); err != nil {
return "", fmt.Errorf("tailscale start: %w", err)
if dataDir == "" {
return "", fmt.Errorf("tailscale data dir is empty")
}
tsDataDir = filepath.Join(dataDir, "tailscale")
if err := os.MkdirAll(tsDataDir, 0700); err != nil {
return "", fmt.Errorf("create tailscale dir: %w", err)
}
tsSocket = filepath.Join(tsDataDir, "tailscaled.sock")
stateFile := filepath.Join(tsDataDir, "tailscaled.state")
log.Printf("Starting tailscaled for node %s", nodeID)
tsCmd = exec.Command(tailscaleBin("tailscaled"),
"--state="+stateFile,
"--socket="+tsSocket,
"--tun=userspace-networking",
)
tsCmd.Stdout = os.Stdout
tsCmd.Stderr = os.Stderr
if err := tsCmd.Start(); err != nil {
tsCmd = nil
return "", fmt.Errorf("start tailscaled: %w", err)
}
globalTSServer = s
// Give tailscaled a moment to start listening.
time.Sleep(1 * time.Second)
// Wait for Tailscale to come up and retrieve IP
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
// Bring the interface up with the auth key.
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
lc, err := s.LocalClient()
if err != nil {
return "", fmt.Errorf("tailscale local client: %w", err)
upCmd := exec.CommandContext(ctx, tailscaleBin("tailscale"),
"--socket="+tsSocket,
"up",
"--authkey="+authKey,
"--login-server="+headscaleURL,
"--hostname="+nodeID,
"--accept-dns=false",
"--operator=root",
)
upCmd.Stdout = os.Stdout
upCmd.Stderr = os.Stderr
if err := upCmd.Run(); err != nil {
_ = tsCmd.Process.Kill()
_ = tsCmd.Wait()
tsCmd = nil
return "", fmt.Errorf("tailscale up: %w", err)
}
var tailscaleIP string
// Wait for an IP address.
for {
status, err := lc.Status(ctx)
out, err := exec.CommandContext(ctx, tailscaleBin("tailscale"),
"--socket="+tsSocket,
"status", "--json",
).Output()
if err != nil {
return "", fmt.Errorf("tailscale status: %w", err)
select {
case <-ctx.Done():
_ = tsCmd.Process.Kill()
_ = tsCmd.Wait()
tsCmd = nil
return "", fmt.Errorf("tailscale status: %w", err)
default:
time.Sleep(1 * time.Second)
continue
}
}
if status.Self != nil && len(status.Self.TailscaleIPs) > 0 {
tailscaleIP = status.Self.TailscaleIPs[0].String()
var st tailscaleStatus
if err := json.Unmarshal(out, &st); err == nil && len(st.Self.TailscaleIPs) > 0 {
tsIP = st.Self.TailscaleIPs[0]
break
}
select {
case <-ctx.Done():
_ = tsCmd.Process.Kill()
_ = tsCmd.Wait()
tsCmd = nil
return "", fmt.Errorf("tailscale IP timeout")
case <-time.After(1 * time.Second):
default:
time.Sleep(1 * time.Second)
}
}
log.Printf("Tailscale started with IP: %s", tailscaleIP)
return tailscaleIP, nil
log.Printf("Tailscale started with IP: %s", tsIP)
return tsIP, 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
func stopTailscale() {
tsCmdMu.Lock()
defer tsCmdMu.Unlock()
stopTailscaleLocked()
}
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)
func stopTailscaleLocked() {
if tsCmd == nil || tsCmd.Process == nil {
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
if tsSocket != "" {
_ = exec.Command(tailscaleBin("tailscale"), "--socket="+tsSocket, "down").Run()
}
_ = tsCmd.Process.Kill()
_ = tsCmd.Wait()
tsCmd = nil
tsIP = ""
log.Printf("Tailscale stopped")
}
func isTailscaleRunning() bool {
tsCmdMu.Lock()
defer tsCmdMu.Unlock()
if tsCmd == nil || tsCmd.Process == nil {
return false
}
// Signal 0 checks process existence without affecting it.
return tsCmd.Process.Signal(syscall.Signal(0)) == nil
}
func getTailscaleIP() string {
tsCmdMu.Lock()
defer tsCmdMu.Unlock()
return tsIP
}
+54 -2
View File
@@ -2,9 +2,12 @@ package main
import (
_ "embed"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"github.com/gorilla/websocket"
)
@@ -20,6 +23,55 @@ func startUI(dataDir, nodeID, serverAddr string) {
fmt.Fprint(w, uiHTML)
})
http.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
cfg, _, err := loadOrCreateConfig(dataDir)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Do not expose the auth key in plain GET unless requested; for local UI it is fine.
json.NewEncoder(w).Encode(cfg)
case http.MethodPost:
var cfg AgentConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if cfg.DataDir == "" {
cfg.DataDir = dataDir
}
if err := saveConfig(dataDir, &cfg); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
http.HandleFunc("/api/restart", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusNoContent)
go func() {
cmd := exec.Command(os.Args[0], os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Start(); err != nil {
log.Printf("Restart failed: %v", err)
return
}
os.Exit(0)
}()
})
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
@@ -72,9 +124,9 @@ func startUI(dataDir, nodeID, serverAddr string) {
})
port := "7070"
log.Printf("UI starting on http://localhost:%s", port)
log.Printf("%s UI starting on http://localhost:%s", APP_NAME, port)
if err := http.ListenAndServe("127.0.0.1:"+port, nil); err != nil {
log.Fatalf("UI server error: %v", err)
log.Fatalf("%s UI server error: %v", APP_NAME, err)
}
}
+101 -3
View File
@@ -2,7 +2,7 @@
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>EduBox Agent</title>
<title>studioE5 Agent</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; background: #f1f5f9; margin: 0; padding: 2rem; color: #1e293b; }
@@ -10,9 +10,13 @@
.card { background: white; border-radius: 12px; padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 1rem; }
h1 { font-size: 1.5rem; margin: 0 0 1rem; }
h2 { font-size: 1.125rem; margin: 0 0 1rem; }
label { display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.25rem; color: #475569; }
input { width: 100%; padding: 0.6rem; border: 1px solid #cbd5e1; border-radius: 8px; margin-bottom: 0.75rem; font-size: 1rem; }
input:read-only { background: #f1f5f9; }
button { width: 100%; padding: 0.7rem; background: #2563eb; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 1rem; }
button:hover { background: #1d4ed8; }
button.secondary { background: #e2e8f0; color: #1e293b; }
button.secondary:hover { background: #cbd5e1; }
.status { margin-top: 0.75rem; font-size: 0.9rem; min-height: 1.2rem; }
.success { color: #16a34a; }
.error { color: #dc2626; }
@@ -30,16 +34,44 @@
.instance-link { font-size: 0.875rem; color: #2563eb; text-decoration: none; font-weight: 500; }
.instance-link:hover { text-decoration: underline; }
.empty { text-align: center; color: #64748b; padding: 1rem 0; }
.toolbar { display: flex; gap: 0.5rem; margin-top: 1rem; }
.toolbar button { flex: 1; }
.note { font-size: 0.8rem; color: #64748b; margin-top: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<h1>EduBox Agent</h1>
<div id="home-card" class="card">
<h1>studioE5 Agent</h1>
<div id="main">
<p class="info">Connexion en cours...</p>
</div>
</div>
<div id="settings-card" class="card" style="display:none;">
<h2>Paramètres</h2>
<form id="settings-form" onsubmit="saveSettings(event)">
<label for="cfg-server">Serveur WebSocket</label>
<input type="text" id="cfg-server" placeholder="ws://localhost:3001">
<label for="cfg-node">ID du nœud</label>
<input type="text" id="cfg-node" placeholder="MON-PC">
<label for="cfg-headscale-url">URL Headscale</label>
<input type="text" id="cfg-headscale-url" placeholder="https://headscale.exemple.com">
<label for="cfg-headscale-key">Clé Headscale</label>
<input type="password" id="cfg-headscale-key" placeholder="hskey-auth-...">
<label for="cfg-data-dir">Répertoire de données</label>
<input type="text" id="cfg-data-dir" readonly>
<button type="submit">Enregistrer et redémarrer</button>
</form>
<div id="settings-status" class="status"></div>
<p class="note">Le redémarrage est nécessaire pour prendre en compte les nouveaux paramètres.</p>
</div>
<div id="instances-card" class="card" style="display:none;">
<h2>Mes instances</h2>
<div id="instances" class="instance-list"></div>
@@ -49,6 +81,8 @@
<script>
const ws = new WebSocket('ws://' + location.host + '/ws');
const main = document.getElementById('main');
const homeCard = document.getElementById('home-card');
const settingsCard = document.getElementById('settings-card');
const instancesCard = document.getElementById('instances-card');
const instancesContainer = document.getElementById('instances');
@@ -60,6 +94,7 @@
const msg = JSON.parse(ev.data);
if (msg.action === 'not_activated') {
showHome();
main.innerHTML = `
<p>Entre ton code d'activation (6 caractères) :</p>
<input type="text" id="code" maxlength="6" placeholder="XXXXXX" onkeydown="if(event.key==='Enter')activate()">
@@ -67,9 +102,13 @@
<div id="status" class="status"></div>
`;
} else if (msg.action === 'activated') {
showHome();
main.innerHTML = `
<p class="success">✅ Activé : <strong>${escapeHtml(msg.studentName || '')}</strong></p>
<p class="info">Tes instances apparaissent ci-dessous.</p>
<div class="toolbar">
<button class="secondary" onclick="showSettings()">⚙️ Paramètres</button>
</div>
`;
instancesCard.style.display = 'block';
ws.send(JSON.stringify({action: 'instances'}));
@@ -130,6 +169,65 @@
}).join('');
}
async function loadSettings() {
try {
const res = await fetch('/api/config');
const cfg = await res.json();
document.getElementById('cfg-server').value = cfg.server || '';
document.getElementById('cfg-node').value = cfg.node_id || '';
document.getElementById('cfg-headscale-url').value = cfg.headscale_url || '';
document.getElementById('cfg-headscale-key').value = cfg.headscale_auth_key || '';
document.getElementById('cfg-data-dir').value = cfg.data_dir || '';
} catch (err) {
document.getElementById('settings-status').innerHTML = `<span class="error">Erreur chargement config</span>`;
}
}
async function saveSettings(event) {
event.preventDefault();
const status = document.getElementById('settings-status');
status.innerHTML = 'Enregistrement...';
const cfg = {
server: document.getElementById('cfg-server').value.trim(),
node_id: document.getElementById('cfg-node').value.trim(),
headscale_url: document.getElementById('cfg-headscale-url').value.trim(),
headscale_auth_key: document.getElementById('cfg-headscale-key').value.trim(),
data_dir: document.getElementById('cfg-data-dir').value.trim()
};
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(cfg)
});
if (res.ok) {
status.innerHTML = '<span class="success">✅ Enregistré. Redémarrage en cours...</span>';
await fetch('/api/restart', {method: 'POST'});
setTimeout(() => location.reload(), 3000);
} else {
status.innerHTML = `<span class="error">❌ Erreur ${res.status}</span>`;
}
} catch (err) {
status.innerHTML = `<span class="error">❌ ${escapeHtml(err.message)}</span>`;
}
}
function showSettings() {
homeCard.style.display = 'none';
instancesCard.style.display = 'none';
settingsCard.style.display = 'block';
loadSettings();
}
function showHome() {
homeCard.style.display = 'block';
settingsCard.style.display = 'none';
}
if (location.hash === '#settings') {
showSettings();
}
function escapeHtml(text) {
if (text == null) return '';
return String(text)
+64 -50
View File
@@ -3,7 +3,6 @@ package main
import (
"fmt"
"log"
"net"
"sync"
"time"
@@ -29,11 +28,6 @@ var (
mainConnMu sync.Mutex
)
var (
tsProxies = make(map[int]net.Listener)
tsProxiesMu sync.Mutex
)
func sendMessage(msg WSMessage) error {
mainConnMu.Lock()
defer mainConnMu.Unlock()
@@ -86,7 +80,7 @@ func notifyUI(msg map[string]interface{}) {
}
}
func startWebSocket(serverAddr, nodeID, dataDir string) {
func startWebSocket(serverAddr, nodeID, dataDir, headscaleURL, headscaleAuthKey string) {
for {
conn, _, err := websocket.DefaultDialer.Dial(serverAddr, nil)
if err != nil {
@@ -144,7 +138,7 @@ func startWebSocket(serverAddr, nodeID, dataDir string) {
break
}
log.Printf("WS received from server: action=%s", msg.Action)
handleMessage(conn, msg, dataDir, nodeID)
handleMessage(conn, msg, dataDir, nodeID, headscaleURL, headscaleAuthKey)
}
close(done)
@@ -157,7 +151,7 @@ func startWebSocket(serverAddr, nodeID, dataDir string) {
}
}
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string) {
func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID, headscaleURL, headscaleAuthKey string) {
switch msg.Action {
case "activated":
log.Printf("handleMessage: activated received, student=%s", msg.StudentName)
@@ -176,6 +170,34 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
case "registered":
// Server acknowledged our register message; nothing to do.
return
case "start_vpn":
log.Printf("Server requested VPN start")
if headscaleURL == "" || headscaleAuthKey == "" {
log.Printf("Cannot start VPN: headscale config missing")
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: "headscale config missing"})
return
}
go func() {
ip, err := startTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey)
if err != nil {
log.Printf("start_vpn error: %v", err)
sendMessage(WSMessage{Action: "vpn_error", NodeID: nodeID, Error: err.Error()})
return
}
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
}
}()
case "stop_vpn":
log.Printf("Server requested VPN stop")
stopTailscale()
sendMessage(WSMessage{Action: "vpn_stopped", NodeID: nodeID})
case "activation_failed":
log.Printf("handleMessage: activation_failed received, error=%s", msg.Error)
notifyUI(map[string]interface{}{
@@ -192,7 +214,7 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
}); err != nil {
log.Printf("upsertInstance error: %v", err)
}
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig); err != nil {
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig, msg.Port); err != nil {
log.Printf("writeCompose error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
@@ -205,7 +227,7 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
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.
// so the studioE5 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)
@@ -213,16 +235,8 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
log.Printf("stripWordPressHardcodedURLs error: %v", err)
}
}()
// 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()
// Ensure Tailscale is running so the server can reach the node
go ensureTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey, msg.Port)
status := getInstanceStatus(dataDir, msg.InstanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
@@ -230,15 +244,6 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
notifyUI(map[string]interface{}{"action": "instances_updated"})
case "stop":
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 {
log.Printf("dockerComposeDown error: %v", err)
}
@@ -249,21 +254,13 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
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":
log.Printf("Reset instance %s", msg.InstanceID)
dockerComposeRm(dataDir, msg.InstanceID)
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig); err != nil {
if err := writeCompose(dataDir, msg.InstanceID, msg.ComposeConfig, msg.Port); err != nil {
log.Printf("writeCompose error: %v", err)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: "error"})
sendMessage(WSMessage{Action: "instance_error", InstanceID: msg.InstanceID, Error: err.Error()})
@@ -276,7 +273,7 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
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.
// so the studioE5 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)
@@ -284,16 +281,8 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
log.Printf("stripWordPressHardcodedURLs error: %v", err)
}
}()
// 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()
// Ensure Tailscale is running so the server can reach the node
go ensureTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey, msg.Port)
status := getInstanceStatus(dataDir, msg.InstanceID)
_ = upsertInstance(dataDir, &InstanceInfo{ID: msg.InstanceID, TemplateName: msg.Type, Port: msg.Port, Status: status})
@@ -303,3 +292,28 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
log.Printf("Unknown action: %s", msg.Action)
}
}
func ensureTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey string, port int) {
if headscaleURL == "" || headscaleAuthKey == "" {
log.Printf("Cannot ensure Tailscale: headscale config missing")
return
}
if isTailscaleRunning() {
return
}
log.Printf("Tailscale not running, starting it for instance port %d", port)
ip, err := startTailscale(dataDir, nodeID, headscaleURL, headscaleAuthKey)
if err != nil {
log.Printf("ensureTailscale start error: %v", err)
return
}
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
}
}