fix uploading and more RBAC refinements for auto deploy
This commit is contained in:
@@ -742,7 +742,12 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func HandleFileUploadHTTP(ctx context.Context, env *agentRuntime.Env, cmdID string, destPath string, sourceURL string, expectedSize int64) error {
|
func HandleFileUploadHTTP(ctx context.Context, env *agentRuntime.Env, cmdID string, destPath string, sourceURL string, expectedSize int64) error {
|
||||||
parsed, err := url.Parse(strings.TrimSpace(sourceURL))
|
resolvedURL, err := resolveUploadPullURL(env, sourceURL)
|
||||||
|
if err != nil {
|
||||||
|
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(resolvedURL)
|
||||||
if err != nil || parsed == nil || parsed.Host == "" {
|
if err != nil || parsed == nil || parsed.Host == "" {
|
||||||
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "invalid upload url"})
|
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "invalid upload url"})
|
||||||
}
|
}
|
||||||
@@ -951,6 +956,64 @@ func nextBackoff(d time.Duration) time.Duration {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveUploadPullURL(env *agentRuntime.Env, raw string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", errors.New("missing upload url")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid upload url: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.IsAbs() {
|
||||||
|
scheme := strings.ToLower(parsed.Scheme)
|
||||||
|
if scheme != "http" && scheme != "https" {
|
||||||
|
return "", fmt.Errorf("unsupported upload url scheme: %s", parsed.Scheme)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(parsed.Path, "/api/file/upload/pull/") {
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(env.Cfg.ServerURLs) == 0 {
|
||||||
|
if parsed.IsAbs() {
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
|
return "", errors.New("no server url configured for upload pull")
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := env.Cfg.ServerIndex
|
||||||
|
if idx < 0 || idx >= len(env.Cfg.ServerURLs) {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
server, err := url.Parse(env.Cfg.ServerURLs[idx])
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid agent server url: %w", err)
|
||||||
|
}
|
||||||
|
switch strings.ToLower(server.Scheme) {
|
||||||
|
case "wss":
|
||||||
|
server.Scheme = "https"
|
||||||
|
case "ws":
|
||||||
|
server.Scheme = "http"
|
||||||
|
case "https", "http":
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported agent server scheme: %s", server.Scheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Path == "" {
|
||||||
|
return "", errors.New("missing upload pull path")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(parsed.Path, "/") {
|
||||||
|
parsed.Path = "/" + parsed.Path
|
||||||
|
}
|
||||||
|
server.Path = parsed.Path
|
||||||
|
server.RawQuery = parsed.RawQuery
|
||||||
|
server.Fragment = ""
|
||||||
|
return server.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func HandleFileDelete(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error {
|
func HandleFileDelete(ctx context.Context, env *agentRuntime.Env, cmdID string, path string) error {
|
||||||
log.Printf("file_delete: %s", path)
|
log.Printf("file_delete: %s", path)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"overlord-client/cmd/agent/config"
|
"overlord-client/cmd/agent/config"
|
||||||
@@ -522,6 +523,61 @@ func TestHandleFileUploadHTTP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleFileUploadHTTP_RewritesUploadPullURLToAgentServer(t *testing.T) {
|
||||||
|
data := []byte("wan-origin-upload-payload")
|
||||||
|
var gotHost string
|
||||||
|
var gotPath string
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotHost = r.Host
|
||||||
|
gotPath = r.URL.RequestURI()
|
||||||
|
if gotPath != "/api/file/upload/pull/test-id?token=abc" {
|
||||||
|
t.Fatalf("expected rewritten upload pull path, got %q", gotPath)
|
||||||
|
}
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
destPath := filepath.Join(tmpDir, "uploaded-http-rewritten.bin")
|
||||||
|
|
||||||
|
writer := &testWriter{}
|
||||||
|
env := &rt.Env{
|
||||||
|
Conn: writer,
|
||||||
|
Cfg: config.Config{
|
||||||
|
ServerURLs: []string{strings.Replace(ts.URL, "http://", "ws://", 1)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
publicOriginURL := "https://public.example.invalid/api/file/upload/pull/test-id?token=abc"
|
||||||
|
if err := HandleFileUploadHTTP(context.Background(), env, "cmd-http-upload-rewrite", destPath, publicOriginURL, int64(len(data))); err != nil {
|
||||||
|
t.Fatalf("HandleFileUploadHTTP failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotHost == "" || strings.Contains(gotHost, "public.example.invalid") {
|
||||||
|
t.Fatalf("expected request to active agent server, got host %q", gotHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(destPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed reading uploaded file: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(content, data) {
|
||||||
|
t.Fatalf("uploaded content mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(writer.msgs) != 1 {
|
||||||
|
t.Fatalf("expected 1 message, got %d", len(writer.msgs))
|
||||||
|
}
|
||||||
|
var result wire.CommandResult
|
||||||
|
if err := msgpack.Unmarshal(writer.msgs[0], &result); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal command result: %v", err)
|
||||||
|
}
|
||||||
|
if !result.OK {
|
||||||
|
t.Fatalf("expected OK=true, got false: %s", result.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleFileUploadHTTP_ResumesWithRangeAfterShortRead(t *testing.T) {
|
func TestHandleFileUploadHTTP_ResumesWithRangeAfterShortRead(t *testing.T) {
|
||||||
data := bytes.Repeat([]byte("range-retry-"), 128*1024)
|
data := bytes.Repeat([]byte("range-retry-"), 128*1024)
|
||||||
firstLen := len(data) / 3
|
firstLen := len(data) / 3
|
||||||
|
|||||||
@@ -119,8 +119,11 @@ func runClient(cfg config.Config) {
|
|||||||
|
|
||||||
conn.SetReadLimit(8 * 1024 * 1024)
|
conn.SetReadLimit(8 * 1024 * 1024)
|
||||||
|
|
||||||
|
sessionCfg := cfg
|
||||||
|
sessionCfg.ServerIndex = currentIndex
|
||||||
|
|
||||||
var sessionErr error
|
var sessionErr error
|
||||||
if err := runSession(ctx, cancel, conn, cfg); err != nil {
|
if err := runSession(ctx, cancel, conn, sessionCfg); err != nil {
|
||||||
sessionErr = err
|
sessionErr = err
|
||||||
log.Printf("session ended: %v (retrying in %s)", err, backoff)
|
log.Printf("session ended: %v (retrying in %s)", err, backoff)
|
||||||
lastDisconnect = time.Now()
|
lastDisconnect = time.Now()
|
||||||
|
|||||||
@@ -498,7 +498,7 @@ executeBtn.addEventListener("click", async () => {
|
|||||||
if (r.ok === false) {
|
if (r.ok === false) {
|
||||||
return { clientName, clientId: r.clientId, error: r.reason || "Dispatch failed" };
|
return { clientName, clientId: r.clientId, error: r.reason || "Dispatch failed" };
|
||||||
}
|
}
|
||||||
return { clientName, clientId: r.clientId, output: "Queued", command: r.command };
|
return { clientName, clientId: r.clientId, output: "Uploaded and execution started", command: r.command };
|
||||||
});
|
});
|
||||||
|
|
||||||
outputContainer.innerHTML = namedResults
|
outputContainer.innerHTML = namedResults
|
||||||
|
|||||||
@@ -2635,10 +2635,10 @@ async function uploadFileViaHttpPull(file, path, transfer) {
|
|||||||
let waitResult;
|
let waitResult;
|
||||||
if (uploadData.agentNotified && typeof uploadData.agentCommandId === "string") {
|
if (uploadData.agentNotified && typeof uploadData.agentCommandId === "string") {
|
||||||
commandId = uploadData.agentCommandId;
|
commandId = uploadData.agentCommandId;
|
||||||
waitResult = waitForCommandResult(commandId, 12 * 60 * 1000);
|
waitResult = waitForCommandResult(commandId, HTTP_UPLOAD_AGENT_TIMEOUT_MS);
|
||||||
} else {
|
} else {
|
||||||
commandId = `upload-http-${Date.now()}-${Math.random()}`;
|
commandId = `upload-http-${Date.now()}-${Math.random()}`;
|
||||||
waitResult = waitForCommandResult(commandId, 12 * 60 * 1000);
|
waitResult = waitForCommandResult(commandId, HTTP_UPLOAD_AGENT_TIMEOUT_MS);
|
||||||
send({
|
send({
|
||||||
type: "command",
|
type: "command",
|
||||||
commandType: "file_upload_http",
|
commandType: "file_upload_http",
|
||||||
@@ -2670,6 +2670,7 @@ const WS_UPLOAD_MAX_TOTAL = 8 * 1024 * 1024;
|
|||||||
const WS_UPLOAD_CHUNK_SIZE = 512 * 1024;
|
const WS_UPLOAD_CHUNK_SIZE = 512 * 1024;
|
||||||
const WS_UPLOAD_CONCURRENCY = 4;
|
const WS_UPLOAD_CONCURRENCY = 4;
|
||||||
const WS_UPLOAD_ACK_TIMEOUT_MS = 90 * 1000;
|
const WS_UPLOAD_ACK_TIMEOUT_MS = 90 * 1000;
|
||||||
|
const HTTP_UPLOAD_AGENT_TIMEOUT_MS = 30 * 60 * 1000;
|
||||||
const PREFER_HTTP_UPLOAD_PULL = true;
|
const PREFER_HTTP_UPLOAD_PULL = true;
|
||||||
|
|
||||||
async function uploadFileViaWsChunks(file, path, transfer) {
|
async function uploadFileViaWsChunks(file, path, transfer) {
|
||||||
|
|||||||
@@ -2250,6 +2250,7 @@ export type AutoDeploy = {
|
|||||||
hideWindow: boolean;
|
hideWindow: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
osFilter: string[];
|
osFilter: string[];
|
||||||
|
createdByUserId: number | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
@@ -2272,6 +2273,7 @@ function mapAutoDeployRow(row: any): AutoDeploy {
|
|||||||
hideWindow: row.hide_window === 1,
|
hideWindow: row.hide_window === 1,
|
||||||
enabled: row.enabled === 1,
|
enabled: row.enabled === 1,
|
||||||
osFilter,
|
osFilter,
|
||||||
|
createdByUserId: row.created_by_user_id == null ? null : Number(row.created_by_user_id),
|
||||||
createdAt: Number(row.created_at) || 0,
|
createdAt: Number(row.created_at) || 0,
|
||||||
updatedAt: Number(row.updated_at) || 0,
|
updatedAt: Number(row.updated_at) || 0,
|
||||||
};
|
};
|
||||||
@@ -2282,6 +2284,14 @@ export function listAutoDeploys(): AutoDeploy[] {
|
|||||||
return rows.map(mapAutoDeployRow);
|
return rows.map(mapAutoDeployRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listAutoDeploysForUser(userId: number, role: string): AutoDeploy[] {
|
||||||
|
if (role === "admin") return listAutoDeploys();
|
||||||
|
const rows = db
|
||||||
|
.query<any>(`SELECT * FROM auto_deploys WHERE created_by_user_id=? ORDER BY created_at DESC`)
|
||||||
|
.all(userId);
|
||||||
|
return rows.map(mapAutoDeployRow);
|
||||||
|
}
|
||||||
|
|
||||||
export function getAutoDeploysByTrigger(trigger: AutoDeployTrigger): AutoDeploy[] {
|
export function getAutoDeploysByTrigger(trigger: AutoDeployTrigger): AutoDeploy[] {
|
||||||
const rows = db
|
const rows = db
|
||||||
.query<any>(
|
.query<any>(
|
||||||
@@ -2296,6 +2306,14 @@ export function getAutoDeploy(id: string): AutoDeploy | null {
|
|||||||
return row ? mapAutoDeployRow(row) : null;
|
return row ? mapAutoDeployRow(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canUserManageAutoDeploy(userId: number, role: string, deployId: string): boolean {
|
||||||
|
if (role === "admin") return true;
|
||||||
|
const row = db
|
||||||
|
.query<{ created_by_user_id: number | null }>(`SELECT created_by_user_id FROM auto_deploys WHERE id=?`)
|
||||||
|
.get(deployId);
|
||||||
|
return row?.created_by_user_id != null && Number(row.created_by_user_id) === userId;
|
||||||
|
}
|
||||||
|
|
||||||
export function createAutoDeploy(input: {
|
export function createAutoDeploy(input: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -2308,11 +2326,12 @@ export function createAutoDeploy(input: {
|
|||||||
hideWindow: boolean;
|
hideWindow: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
osFilter: string[];
|
osFilter: string[];
|
||||||
|
createdByUserId: number;
|
||||||
}): AutoDeploy {
|
}): AutoDeploy {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT INTO auto_deploys (id, name, trigger, file_path, file_name, file_size, file_os, args, hide_window, enabled, os_filter, created_at, updated_at)
|
`INSERT INTO auto_deploys (id, name, trigger, file_path, file_name, file_size, file_os, args, hide_window, enabled, os_filter, created_by_user_id, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
input.id,
|
input.id,
|
||||||
input.name,
|
input.name,
|
||||||
input.trigger,
|
input.trigger,
|
||||||
@@ -2324,6 +2343,7 @@ export function createAutoDeploy(input: {
|
|||||||
input.hideWindow ? 1 : 0,
|
input.hideWindow ? 1 : 0,
|
||||||
input.enabled ? 1 : 0,
|
input.enabled ? 1 : 0,
|
||||||
JSON.stringify(input.osFilter ?? []),
|
JSON.stringify(input.osFilter ?? []),
|
||||||
|
input.createdByUserId,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -446,6 +446,7 @@ db.run(`
|
|||||||
hide_window INTEGER NOT NULL DEFAULT 1,
|
hide_window INTEGER NOT NULL DEFAULT 1,
|
||||||
enabled INTEGER NOT NULL,
|
enabled INTEGER NOT NULL,
|
||||||
os_filter TEXT NOT NULL DEFAULT '[]',
|
os_filter TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_by_user_id INTEGER,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
@@ -453,6 +454,15 @@ db.run(`
|
|||||||
db.run(
|
db.run(
|
||||||
`CREATE INDEX IF NOT EXISTS idx_auto_deploys_trigger ON auto_deploys(trigger, enabled);`,
|
`CREATE INDEX IF NOT EXISTS idx_auto_deploys_trigger ON auto_deploys(trigger, enabled);`,
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
|
const columns = db.query<{ name: string }>(`PRAGMA table_info(auto_deploys)`).all();
|
||||||
|
if (!columns.some((column) => column.name === "created_by_user_id")) {
|
||||||
|
db.run(`ALTER TABLE auto_deploys ADD COLUMN created_by_user_id INTEGER`);
|
||||||
|
}
|
||||||
|
db.run(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_auto_deploys_created_by_user_id ON auto_deploys(created_by_user_id);`,
|
||||||
|
);
|
||||||
|
} catch { /* legacy migration will retry on next startup */ }
|
||||||
|
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS auto_deploy_runs (
|
CREATE TABLE IF NOT EXISTS auto_deploy_runs (
|
||||||
|
|||||||
@@ -499,6 +499,7 @@ async function startServer() {
|
|||||||
deploy: {
|
deploy: {
|
||||||
DEPLOY_ROOT,
|
DEPLOY_ROOT,
|
||||||
deployUploads,
|
deployUploads,
|
||||||
|
pendingCommandReplies,
|
||||||
detectUploadOs,
|
detectUploadOs,
|
||||||
normalizeClientOs,
|
normalizeClientOs,
|
||||||
},
|
},
|
||||||
@@ -621,9 +622,7 @@ async function startServer() {
|
|||||||
handleDesktopAudioViewerMessage,
|
handleDesktopAudioViewerMessage,
|
||||||
dispatchAutoScriptsForConnection,
|
dispatchAutoScriptsForConnection,
|
||||||
dispatchAutoDeploysForConnection: (info: import("./types").ClientInfo, ws: import("bun").ServerWebSocket<SocketData>) => {
|
dispatchAutoDeploysForConnection: (info: import("./types").ClientInfo, ws: import("bun").ServerWebSocket<SocketData>) => {
|
||||||
const proto = tls ? "https" : "http";
|
dispatchAutoDeploysForConnection(info, ws, { pendingCommandReplies });
|
||||||
const origin = `${proto}://127.0.0.1:${PORT}`;
|
|
||||||
dispatchAutoDeploysForConnection(info, ws, origin);
|
|
||||||
},
|
},
|
||||||
dispatchAutoLoadPlugins: (info: import("./types").ClientInfo) => {
|
dispatchAutoLoadPlugins: (info: import("./types").ClientInfo) => {
|
||||||
dispatchAutoLoadPlugins(
|
dispatchAutoLoadPlugins(
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
createAutoDeploy,
|
||||||
|
deleteAutoDeploy,
|
||||||
|
deleteClientRow,
|
||||||
|
upsertClientRow,
|
||||||
|
} from "../db";
|
||||||
|
import { decodeMessage } from "../protocol";
|
||||||
|
import {
|
||||||
|
createUser,
|
||||||
|
deleteUser,
|
||||||
|
getUserById,
|
||||||
|
setUserClientAccessRule,
|
||||||
|
setUserClientAccessScope,
|
||||||
|
} from "../users";
|
||||||
|
import type { ClientInfo } from "../types";
|
||||||
|
import { dispatchAutoDeploysForConnection } from "./auto-deploy-dispatch";
|
||||||
|
|
||||||
|
const PASSWORD = "Aa1!AutoDeployDispatchTestPass";
|
||||||
|
|
||||||
|
const createdUserIds: number[] = [];
|
||||||
|
const createdDeployIds: string[] = [];
|
||||||
|
const createdClientIds: string[] = [];
|
||||||
|
const createdDirs: string[] = [];
|
||||||
|
|
||||||
|
async function makeUser(role: "admin" | "operator" | "viewer") {
|
||||||
|
const username = `ad_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||||
|
const result = await createUser(username, PASSWORD, role, "test");
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const user = getUserById(result.userId!);
|
||||||
|
expect(user).not.toBeNull();
|
||||||
|
createdUserIds.push(user!.id);
|
||||||
|
return user!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeClient(id: string, os = "windows") {
|
||||||
|
const sent: Uint8Array[] = [];
|
||||||
|
upsertClientRow({
|
||||||
|
id,
|
||||||
|
os,
|
||||||
|
arch: "x64",
|
||||||
|
online: 1,
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
});
|
||||||
|
createdClientIds.push(id);
|
||||||
|
|
||||||
|
const info: ClientInfo = {
|
||||||
|
id,
|
||||||
|
role: "client",
|
||||||
|
os,
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
ws: {
|
||||||
|
send(message: Uint8Array) {
|
||||||
|
sent.push(message);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewerWs = { data: { wasKnown: true } } as any;
|
||||||
|
return { info, viewerWs, sent };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeAutoDeploy(ownerId: number, name: string) {
|
||||||
|
const id = `auto-deploy-test-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||||
|
const dir = path.resolve(".test-data", "auto-deploy-dispatch", id);
|
||||||
|
const filePath = path.join(dir, "payload.exe");
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
await fs.writeFile(filePath, "payload");
|
||||||
|
createdDirs.push(dir);
|
||||||
|
createdDeployIds.push(id);
|
||||||
|
return createAutoDeploy({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
trigger: "on_connect",
|
||||||
|
filePath,
|
||||||
|
fileName: "payload.exe",
|
||||||
|
fileSize: 7,
|
||||||
|
fileOs: "windows",
|
||||||
|
args: "",
|
||||||
|
hideWindow: true,
|
||||||
|
enabled: true,
|
||||||
|
osFilter: [],
|
||||||
|
createdByUserId: ownerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentCommandTypes(sent: Uint8Array[]): string[] {
|
||||||
|
return sent
|
||||||
|
.map((message) => decodeMessage(message) as any)
|
||||||
|
.map((message) => String(message?.commandType || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePendingOnce(pendingCommandReplies: Map<string, any>) {
|
||||||
|
const first = pendingCommandReplies.entries().next().value;
|
||||||
|
expect(first).toBeTruthy();
|
||||||
|
const [cmdId, pending] = first;
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
pending.resolve({ ok: true, message: "ok" });
|
||||||
|
pendingCommandReplies.delete(cmdId);
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
while (createdDeployIds.length > 0) {
|
||||||
|
const id = createdDeployIds.pop();
|
||||||
|
if (typeof id === "string") deleteAutoDeploy(id);
|
||||||
|
}
|
||||||
|
while (createdClientIds.length > 0) {
|
||||||
|
const id = createdClientIds.pop();
|
||||||
|
if (typeof id === "string") deleteClientRow(id);
|
||||||
|
}
|
||||||
|
while (createdUserIds.length > 0) {
|
||||||
|
const id = createdUserIds.pop();
|
||||||
|
if (typeof id === "number") deleteUser(id);
|
||||||
|
}
|
||||||
|
while (createdDirs.length > 0) {
|
||||||
|
const dir = createdDirs.pop();
|
||||||
|
if (typeof dir === "string") await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auto deploy dispatch", () => {
|
||||||
|
test("operator-owned auto deploys only run for clients allowed by RBAC", async () => {
|
||||||
|
const operator = await makeUser("operator");
|
||||||
|
setUserClientAccessScope(operator.id, "allowlist");
|
||||||
|
setUserClientAccessRule(operator.id, "ad-allowed-client", "allow");
|
||||||
|
await makeAutoDeploy(operator.id, "operator scoped");
|
||||||
|
|
||||||
|
const allowedPending = new Map<string, any>();
|
||||||
|
const allowed = makeClient("ad-allowed-client");
|
||||||
|
dispatchAutoDeploysForConnection(allowed.info, allowed.viewerWs, {
|
||||||
|
pendingCommandReplies: allowedPending,
|
||||||
|
});
|
||||||
|
expect(sentCommandTypes(allowed.sent)).toContain("file_upload_http");
|
||||||
|
expect(sentCommandTypes(allowed.sent)).not.toContain("silent_exec");
|
||||||
|
|
||||||
|
await resolvePendingOnce(allowedPending);
|
||||||
|
expect(sentCommandTypes(allowed.sent)).toContain("silent_exec");
|
||||||
|
await resolvePendingOnce(allowedPending);
|
||||||
|
|
||||||
|
const blockedPending = new Map<string, any>();
|
||||||
|
const blocked = makeClient("ad-blocked-client");
|
||||||
|
dispatchAutoDeploysForConnection(blocked.info, blocked.viewerWs, {
|
||||||
|
pendingCommandReplies: blockedPending,
|
||||||
|
});
|
||||||
|
expect(sentCommandTypes(blocked.sent)).not.toContain("file_upload_http");
|
||||||
|
expect(sentCommandTypes(blocked.sent)).not.toContain("silent_exec");
|
||||||
|
expect(blockedPending.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,11 +9,86 @@ import type { ClientInfo } from "../types";
|
|||||||
import { logAudit, AuditAction } from "../auditLog";
|
import { logAudit, AuditAction } from "../auditLog";
|
||||||
import { normalizeClientOs } from "./deploy-utils";
|
import { normalizeClientOs } from "./deploy-utils";
|
||||||
import { createUploadPull } from "./file-transfer-state";
|
import { createUploadPull } from "./file-transfer-state";
|
||||||
|
import { canUserAccessClient, getUserById } from "../users";
|
||||||
|
|
||||||
|
const AUTO_DEPLOY_COMMAND_TIMEOUT_MS = 30 * 60 * 1000;
|
||||||
|
const AUTO_DEPLOY_UPLOAD_TTL_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
type PendingCommandResult = { ok: boolean; message?: string };
|
||||||
|
|
||||||
|
type PendingCommandReply = {
|
||||||
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
|
resolve: (value: PendingCommandResult) => void;
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutoDeployDispatchDeps = {
|
||||||
|
pendingCommandReplies: Map<string, PendingCommandReply>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function autoDeployCanRunOnClient(
|
||||||
|
deploy: { id: string; createdByUserId: number | null },
|
||||||
|
clientId: string,
|
||||||
|
): boolean {
|
||||||
|
if (deploy.createdByUserId == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = getUserById(deploy.createdByUserId);
|
||||||
|
if (!owner) {
|
||||||
|
logger.warn(`[auto-deploy] skipping ${deploy.id}: owner user ${deploy.createdByUserId} no longer exists`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (owner.role === "admin") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return canUserAccessClient(owner.id, owner.role, clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoDeployAuditUser(deploy: { createdByUserId: number | null }): string {
|
||||||
|
if (deploy.createdByUserId == null) return "system";
|
||||||
|
const owner = getUserById(deploy.createdByUserId);
|
||||||
|
return owner?.username || `user:${deploy.createdByUserId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForAutoDeployCommand(
|
||||||
|
deps: AutoDeployDispatchDeps,
|
||||||
|
info: ClientInfo,
|
||||||
|
commandType: string,
|
||||||
|
payload: unknown,
|
||||||
|
timeoutMessage: string,
|
||||||
|
): Promise<PendingCommandResult> {
|
||||||
|
const cmdId = uuidv4();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
deps.pendingCommandReplies.delete(cmdId);
|
||||||
|
resolve({ ok: false, message: timeoutMessage });
|
||||||
|
}, AUTO_DEPLOY_COMMAND_TIMEOUT_MS);
|
||||||
|
|
||||||
|
deps.pendingCommandReplies.set(cmdId, { resolve, timeout, clientId: info.id });
|
||||||
|
|
||||||
|
try {
|
||||||
|
info.ws.send(
|
||||||
|
encodeMessage({
|
||||||
|
type: "command",
|
||||||
|
commandType: commandType as any,
|
||||||
|
id: cmdId,
|
||||||
|
payload,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
deps.pendingCommandReplies.delete(cmdId);
|
||||||
|
resolve({ ok: false, message: (err as Error)?.message || "send failed" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function dispatchAutoDeploysForConnection(
|
export function dispatchAutoDeploysForConnection(
|
||||||
info: ClientInfo,
|
info: ClientInfo,
|
||||||
ws: ServerWebSocket<SocketData>,
|
ws: ServerWebSocket<SocketData>,
|
||||||
serverOrigin: string,
|
deps: AutoDeployDispatchDeps,
|
||||||
): void {
|
): void {
|
||||||
if (info.role !== "client") return;
|
if (info.role !== "client") return;
|
||||||
if (ws.data?.autoDeploysRan) return;
|
if (ws.data?.autoDeploysRan) return;
|
||||||
@@ -32,6 +107,10 @@ export function dispatchAutoDeploysForConnection(
|
|||||||
const clientOs = normalizeClientOs(info.os);
|
const clientOs = normalizeClientOs(info.os);
|
||||||
|
|
||||||
for (const deploy of deploys) {
|
for (const deploy of deploys) {
|
||||||
|
if (!autoDeployCanRunOnClient(deploy, info.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (deploy.osFilter.length > 0) {
|
if (deploy.osFilter.length > 0) {
|
||||||
const rawOs = (info.os || "").toLowerCase();
|
const rawOs = (info.os || "").toLowerCase();
|
||||||
if (!deploy.osFilter.includes(rawOs)) {
|
if (!deploy.osFilter.includes(rawOs)) {
|
||||||
@@ -43,7 +122,6 @@ export function dispatchAutoDeploysForConnection(
|
|||||||
if (hasAutoDeployRun(deploy.id, info.id)) {
|
if (hasAutoDeployRun(deploy.id, info.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
recordAutoDeployRun(deploy.id, info.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the file still exists
|
// Verify the file still exists
|
||||||
@@ -70,64 +148,76 @@ export function dispatchAutoDeploysForConnection(
|
|||||||
filePath: deploy.filePath,
|
filePath: deploy.filePath,
|
||||||
fileName: deploy.fileName,
|
fileName: deploy.fileName,
|
||||||
size: deploy.fileSize,
|
size: deploy.fileSize,
|
||||||
ttlMs: 120_000,
|
ttlMs: AUTO_DEPLOY_UPLOAD_TTL_MS,
|
||||||
});
|
});
|
||||||
const pullUrl = `${serverOrigin}/api/file/upload/pull/${encodeURIComponent(pullId)}`;
|
const pullUrl = `/api/file/upload/pull/${encodeURIComponent(pullId)}`;
|
||||||
|
|
||||||
try {
|
void (async () => {
|
||||||
info.ws.send(
|
const auditUser = getAutoDeployAuditUser(deploy);
|
||||||
encodeMessage({
|
try {
|
||||||
type: "command",
|
const uploadResult = await waitForAutoDeployCommand(
|
||||||
commandType: "file_upload_http" as any,
|
deps,
|
||||||
id: uuidv4(),
|
info,
|
||||||
payload: { path: destPath, url: pullUrl, total: deploy.fileSize },
|
"file_upload_http",
|
||||||
}),
|
{ path: destPath, url: pullUrl, total: deploy.fileSize },
|
||||||
);
|
"Timed out waiting for auto-deploy upload to finish",
|
||||||
|
|
||||||
if (clientOs !== "windows") {
|
|
||||||
info.ws.send(
|
|
||||||
encodeMessage({
|
|
||||||
type: "command",
|
|
||||||
commandType: "file_chmod" as any,
|
|
||||||
id: uuidv4(),
|
|
||||||
payload: { path: destPath, mode: "0755" },
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
if (!uploadResult.ok) {
|
||||||
|
throw new Error(uploadResult.message || "auto-deploy upload failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientOs !== "windows") {
|
||||||
|
const chmodResult = await waitForAutoDeployCommand(
|
||||||
|
deps,
|
||||||
|
info,
|
||||||
|
"file_chmod",
|
||||||
|
{ path: destPath, mode: "0755" },
|
||||||
|
"Timed out waiting for auto-deploy chmod to finish",
|
||||||
|
);
|
||||||
|
if (!chmodResult.ok) {
|
||||||
|
throw new Error(chmodResult.message || "auto-deploy chmod failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const execResult = await waitForAutoDeployCommand(
|
||||||
|
deps,
|
||||||
|
info,
|
||||||
|
"silent_exec",
|
||||||
|
{ command: destPath, args: deploy.args, hideWindow: deploy.hideWindow },
|
||||||
|
"Timed out waiting for auto-deploy execution to start",
|
||||||
|
);
|
||||||
|
if (!execResult.ok) {
|
||||||
|
throw new Error(execResult.message || "auto-deploy execution failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deploy.trigger === "on_connect_once") {
|
||||||
|
recordAutoDeployRun(deploy.id, info.id);
|
||||||
|
}
|
||||||
|
metrics.recordCommand("silent_exec");
|
||||||
|
logAudit({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
username: auditUser,
|
||||||
|
ip: "server",
|
||||||
|
action: AuditAction.SILENT_EXECUTE,
|
||||||
|
targetClientId: info.id,
|
||||||
|
success: true,
|
||||||
|
details: `auto-deploy:${deploy.name} trigger=${deploy.trigger} file=${deploy.fileName}`,
|
||||||
|
});
|
||||||
|
logger.info(`[auto-deploy] dispatched ${deploy.id} (${deploy.trigger}) to ${info.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[auto-deploy] failed to dispatch ${deploy.id} to ${info.id}`, err);
|
||||||
|
logAudit({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
username: auditUser,
|
||||||
|
ip: "server",
|
||||||
|
action: AuditAction.SILENT_EXECUTE,
|
||||||
|
targetClientId: info.id,
|
||||||
|
success: false,
|
||||||
|
details: `auto-deploy:${deploy.name} trigger=${deploy.trigger} file=${deploy.fileName}`,
|
||||||
|
errorMessage: (err as Error)?.message || "send failed",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
info.ws.send(
|
|
||||||
encodeMessage({
|
|
||||||
type: "command",
|
|
||||||
commandType: "silent_exec" as any,
|
|
||||||
id: uuidv4(),
|
|
||||||
payload: { command: destPath, args: deploy.args, hideWindow: deploy.hideWindow },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
metrics.recordCommand("silent_exec");
|
|
||||||
logAudit({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
username: "system",
|
|
||||||
ip: "server",
|
|
||||||
action: AuditAction.SILENT_EXECUTE,
|
|
||||||
targetClientId: info.id,
|
|
||||||
success: true,
|
|
||||||
details: `auto-deploy:${deploy.name} trigger=${deploy.trigger} file=${deploy.fileName}`,
|
|
||||||
});
|
|
||||||
logger.info(`[auto-deploy] dispatched ${deploy.id} (${deploy.trigger}) to ${info.id}`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`[auto-deploy] failed to dispatch ${deploy.id} to ${info.id}`, err);
|
|
||||||
logAudit({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
username: "system",
|
|
||||||
ip: "server",
|
|
||||||
action: AuditAction.SILENT_EXECUTE,
|
|
||||||
targetClientId: info.id,
|
|
||||||
success: false,
|
|
||||||
details: `auto-deploy:${deploy.name} trigger=${deploy.trigger} file=${deploy.fileName}`,
|
|
||||||
errorMessage: (err as Error)?.message || "send failed",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.data.autoDeploysRan = true;
|
ws.data.autoDeploysRan = true;
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import fs from "fs/promises";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
import {
|
import {
|
||||||
|
canUserManageAutoDeploy,
|
||||||
createAutoDeploy,
|
createAutoDeploy,
|
||||||
deleteAutoDeploy,
|
deleteAutoDeploy,
|
||||||
getAutoDeploy,
|
getAutoDeploy,
|
||||||
listAutoDeploys,
|
listAutoDeploysForUser,
|
||||||
updateAutoDeploy,
|
updateAutoDeploy,
|
||||||
type AutoDeployTrigger,
|
type AutoDeployTrigger,
|
||||||
} from "../../db";
|
} from "../../db";
|
||||||
@@ -34,13 +35,13 @@ export async function handleAutoDeployRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
requirePermission(user, "clients:control");
|
requirePermission(user, "deploys:manage");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Response) return error;
|
if (error instanceof Response) return error;
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({ items: listAutoDeploys() });
|
return Response.json({ items: listAutoDeploysForUser(user.userId, user.role) });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && url.pathname === "/api/auto-deploys") {
|
if (req.method === "POST" && url.pathname === "/api/auto-deploys") {
|
||||||
@@ -113,6 +114,7 @@ export async function handleAutoDeployRoutes(
|
|||||||
hideWindow,
|
hideWindow,
|
||||||
enabled,
|
enabled,
|
||||||
osFilter,
|
osFilter,
|
||||||
|
createdByUserId: user.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json({ ok: true, item });
|
return Response.json({ ok: true, item });
|
||||||
@@ -151,7 +153,12 @@ export async function handleAutoDeployRoutes(
|
|||||||
? osFilterRaw.filter((v: unknown) => typeof v === "string" && ALLOWED_OS_FILTERS.has(v as string))
|
? osFilterRaw.filter((v: unknown) => typeof v === "string" && ALLOWED_OS_FILTERS.has(v as string))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const updated = updateAutoDeploy(autoDeployMatch[1], {
|
const deployId = autoDeployMatch[1];
|
||||||
|
if (!canUserManageAutoDeploy(user.userId, user.role, deployId)) {
|
||||||
|
return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = updateAutoDeploy(deployId, {
|
||||||
name: body?.name ? String(body.name).trim() : undefined,
|
name: body?.name ? String(body.name).trim() : undefined,
|
||||||
trigger: triggerRaw as AutoDeployTrigger | undefined,
|
trigger: triggerRaw as AutoDeployTrigger | undefined,
|
||||||
args: typeof body?.args === "string" ? body.args : undefined,
|
args: typeof body?.args === "string" ? body.args : undefined,
|
||||||
@@ -182,7 +189,12 @@ export async function handleAutoDeployRoutes(
|
|||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const deploy = getAutoDeploy(autoDeployMatch[1]);
|
const deployId = autoDeployMatch[1];
|
||||||
|
if (!canUserManageAutoDeploy(user.userId, user.role, deployId)) {
|
||||||
|
return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deploy = getAutoDeploy(deployId);
|
||||||
if (deploy) {
|
if (deploy) {
|
||||||
try {
|
try {
|
||||||
const dir = path.dirname(deploy.filePath);
|
const dir = path.dirname(deploy.filePath);
|
||||||
@@ -190,7 +202,7 @@ export async function handleAutoDeployRoutes(
|
|||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteAutoDeploy(autoDeployMatch[1]);
|
deleteAutoDeploy(deployId);
|
||||||
return Response.json({ ok: true });
|
return Response.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,13 +24,62 @@ type DeployUpload = {
|
|||||||
os: DeployOs;
|
os: DeployOs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PendingCommandReply = {
|
||||||
|
resolve: (result: { ok: boolean; message?: string }) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
|
||||||
type DeployRouteDeps = {
|
type DeployRouteDeps = {
|
||||||
DEPLOY_ROOT: string;
|
DEPLOY_ROOT: string;
|
||||||
deployUploads: Map<string, DeployUpload>;
|
deployUploads: Map<string, DeployUpload>;
|
||||||
|
pendingCommandReplies: Map<string, PendingCommandReply>;
|
||||||
detectUploadOs: (filename: string, bytes: Uint8Array) => DeployOs;
|
detectUploadOs: (filename: string, bytes: Uint8Array) => DeployOs;
|
||||||
normalizeClientOs: (os?: string) => DeployOs;
|
normalizeClientOs: (os?: string) => DeployOs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEPLOY_COMMAND_TIMEOUT_MS = 30 * 60_000;
|
||||||
|
|
||||||
|
function waitForClientCommand(
|
||||||
|
deps: DeployRouteDeps,
|
||||||
|
target: any,
|
||||||
|
clientId: string,
|
||||||
|
command: any,
|
||||||
|
timeoutMessage: string,
|
||||||
|
timeoutMs = DEPLOY_COMMAND_TIMEOUT_MS,
|
||||||
|
): Promise<{ ok: boolean; message?: string }> {
|
||||||
|
const cmdId = command.id || uuidv4();
|
||||||
|
command.id = cmdId;
|
||||||
|
|
||||||
|
const replyPromise = new Promise<{ ok: boolean; message?: string }>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
deps.pendingCommandReplies.delete(cmdId);
|
||||||
|
resolve({ ok: false, message: timeoutMessage });
|
||||||
|
}, timeoutMs);
|
||||||
|
deps.pendingCommandReplies.set(cmdId, { resolve, reject, timeout, clientId });
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
target.ws.send(encodeMessage(command));
|
||||||
|
} catch (error) {
|
||||||
|
const pending = deps.pendingCommandReplies.get(cmdId);
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
deps.pendingCommandReplies.delete(cmdId);
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
message: (error as Error)?.message || "Failed to send command",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return replyPromise.catch((error) => ({
|
||||||
|
ok: false,
|
||||||
|
message: (error as Error)?.message || timeoutMessage,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleDeployRoutes(
|
export async function handleDeployRoutes(
|
||||||
req: Request,
|
req: Request,
|
||||||
url: URL,
|
url: URL,
|
||||||
@@ -225,38 +274,63 @@ export async function handleDeployRoutes(
|
|||||||
fileName: upload.name,
|
fileName: upload.name,
|
||||||
size: upload.size,
|
size: upload.size,
|
||||||
});
|
});
|
||||||
const pullUrl = `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`;
|
const pullUrl = `/api/file/upload/pull/${encodeURIComponent(pullId)}`;
|
||||||
|
const uploadResult = await waitForClientCommand(
|
||||||
target.ws.send(
|
deps,
|
||||||
encodeMessage({
|
target,
|
||||||
|
clientId,
|
||||||
|
{
|
||||||
type: "command",
|
type: "command",
|
||||||
commandType: "file_upload_http",
|
commandType: "file_upload_http",
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
payload: { path: destPath, url: pullUrl, total: upload.size },
|
payload: { path: destPath, url: pullUrl, total: upload.size },
|
||||||
}),
|
},
|
||||||
|
"Upload to client timed out",
|
||||||
);
|
);
|
||||||
|
if (!uploadResult.ok) {
|
||||||
|
results.push({ clientId, ok: false, reason: uploadResult.message || "upload_failed" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (clientOs !== "windows") {
|
if (clientOs !== "windows") {
|
||||||
target.ws.send(
|
const chmodResult = await waitForClientCommand(
|
||||||
encodeMessage({
|
deps,
|
||||||
|
target,
|
||||||
|
clientId,
|
||||||
|
{
|
||||||
type: "command",
|
type: "command",
|
||||||
commandType: "file_chmod",
|
commandType: "file_chmod",
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
payload: { path: destPath, mode: "0755" },
|
payload: { path: destPath, mode: "0755" },
|
||||||
}),
|
},
|
||||||
|
"chmod timed out",
|
||||||
|
60_000,
|
||||||
);
|
);
|
||||||
|
if (!chmodResult.ok) {
|
||||||
|
results.push({ clientId, ok: false, reason: chmodResult.message || "chmod_failed" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayCommand = formatCommandDisplay(destPath, args);
|
const displayCommand = formatCommandDisplay(destPath, args);
|
||||||
|
|
||||||
target.ws.send(
|
const execResult = await waitForClientCommand(
|
||||||
encodeMessage({
|
deps,
|
||||||
|
target,
|
||||||
|
clientId,
|
||||||
|
{
|
||||||
type: "command",
|
type: "command",
|
||||||
commandType: "silent_exec",
|
commandType: "silent_exec",
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
payload: { command: destPath, args, hideWindow },
|
payload: { command: destPath, args, hideWindow },
|
||||||
}),
|
},
|
||||||
|
"Execution start timed out",
|
||||||
|
60_000,
|
||||||
);
|
);
|
||||||
|
if (!execResult.ok) {
|
||||||
|
results.push({ clientId, ok: false, reason: execResult.message || "execute_failed" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
metrics.recordCommand("silent_exec");
|
metrics.recordCommand("silent_exec");
|
||||||
logAudit({
|
logAudit({
|
||||||
@@ -342,37 +416,62 @@ export async function handleDeployRoutes(
|
|||||||
fileName: upload.name,
|
fileName: upload.name,
|
||||||
size: upload.size,
|
size: upload.size,
|
||||||
});
|
});
|
||||||
const pullUrl = `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`;
|
const pullUrl = `/api/file/upload/pull/${encodeURIComponent(pullId)}`;
|
||||||
|
const uploadResult = await waitForClientCommand(
|
||||||
target.ws.send(
|
deps,
|
||||||
encodeMessage({
|
target,
|
||||||
|
clientId,
|
||||||
|
{
|
||||||
type: "command",
|
type: "command",
|
||||||
commandType: "file_upload_http",
|
commandType: "file_upload_http",
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
payload: { path: destPath, url: pullUrl, total: upload.size },
|
payload: { path: destPath, url: pullUrl, total: upload.size },
|
||||||
}),
|
},
|
||||||
|
"Upload to client timed out",
|
||||||
);
|
);
|
||||||
|
if (!uploadResult.ok) {
|
||||||
|
results.push({ clientId, ok: false, reason: uploadResult.message || "upload_failed" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (clientOs !== "windows") {
|
if (clientOs !== "windows") {
|
||||||
target.ws.send(
|
const chmodResult = await waitForClientCommand(
|
||||||
encodeMessage({
|
deps,
|
||||||
|
target,
|
||||||
|
clientId,
|
||||||
|
{
|
||||||
type: "command",
|
type: "command",
|
||||||
commandType: "file_chmod",
|
commandType: "file_chmod",
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
payload: { path: destPath, mode: "0755" },
|
payload: { path: destPath, mode: "0755" },
|
||||||
}),
|
},
|
||||||
|
"chmod timed out",
|
||||||
|
60_000,
|
||||||
);
|
);
|
||||||
|
if (!chmodResult.ok) {
|
||||||
|
results.push({ clientId, ok: false, reason: chmodResult.message || "chmod_failed" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = fileHash;
|
const hash = fileHash;
|
||||||
target.ws.send(
|
const updateResult = await waitForClientCommand(
|
||||||
encodeMessage({
|
deps,
|
||||||
|
target,
|
||||||
|
clientId,
|
||||||
|
{
|
||||||
type: "command",
|
type: "command",
|
||||||
commandType: "agent_update",
|
commandType: "agent_update",
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
payload: { path: destPath, hash, hideWindow: true },
|
payload: { path: destPath, hash, hideWindow: true },
|
||||||
}),
|
},
|
||||||
|
"Update start timed out",
|
||||||
|
60_000,
|
||||||
);
|
);
|
||||||
|
if (!updateResult.ok) {
|
||||||
|
results.push({ clientId, ok: false, reason: updateResult.message || "update_failed" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
metrics.recordCommand("agent_update");
|
metrics.recordCommand("agent_update");
|
||||||
logAudit({
|
logAudit({
|
||||||
|
|||||||
@@ -124,8 +124,11 @@ describe("file upload route flow", () => {
|
|||||||
expect(stageJson.ok).toBe(true);
|
expect(stageJson.ok).toBe(true);
|
||||||
expect(stageJson.size).toBe(payload.length);
|
expect(stageJson.size).toBe(payload.length);
|
||||||
expect(typeof stageJson.pullUrl).toBe("string");
|
expect(typeof stageJson.pullUrl).toBe("string");
|
||||||
|
expect(stageJson.pullUrl.startsWith("/api/file/upload/pull/")).toBe(true);
|
||||||
|
expect(stageJson.agentNotified).toBe(false);
|
||||||
|
expect(stageJson.agentCommandId).toBeNull();
|
||||||
|
|
||||||
const pullUrl = new URL(stageJson.pullUrl);
|
const pullUrl = new URL(stageJson.pullUrl, "https://localhost");
|
||||||
const pullRes = await handleFileDownloadRoutes(
|
const pullRes = await handleFileDownloadRoutes(
|
||||||
new Request(pullUrl, {
|
new Request(pullUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -228,7 +231,7 @@ describe("file upload route flow", () => {
|
|||||||
);
|
);
|
||||||
const stageJson = await stageRes!.json() as any;
|
const stageJson = await stageRes!.json() as any;
|
||||||
|
|
||||||
const pullUrl = new URL(stageJson.pullUrl);
|
const pullUrl = new URL(stageJson.pullUrl, "https://localhost");
|
||||||
const badPullRes = await handleFileDownloadRoutes(
|
const badPullRes = await handleFileDownloadRoutes(
|
||||||
new Request(pullUrl, {
|
new Request(pullUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -309,7 +312,7 @@ describe("file upload route flow", () => {
|
|||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
const stageJson = await stageRes!.json() as any;
|
const stageJson = await stageRes!.json() as any;
|
||||||
const pullUrl = new URL(stageJson.pullUrl);
|
const pullUrl = new URL(stageJson.pullUrl, "https://localhost");
|
||||||
|
|
||||||
const firstPullRes = await handleFileDownloadRoutes(
|
const firstPullRes = await handleFileDownloadRoutes(
|
||||||
new Request(pullUrl, {
|
new Request(pullUrl, {
|
||||||
|
|||||||
@@ -403,40 +403,7 @@ export async function handleFileDownloadRoutes(
|
|||||||
uploadIntents.delete(uploadId);
|
uploadIntents.delete(uploadId);
|
||||||
clearTimeout(intent.timeout);
|
clearTimeout(intent.timeout);
|
||||||
|
|
||||||
let agentCommandId: string | null = null;
|
const pullUrl = `/api/file/upload/pull/${encodeURIComponent(pullId)}`;
|
||||||
if (canStream) {
|
|
||||||
const target = clientManager.getClient(intent.clientId);
|
|
||||||
if (target) {
|
|
||||||
agentCommandId = uuidv4();
|
|
||||||
try {
|
|
||||||
target.ws.send(
|
|
||||||
encodeMessage({
|
|
||||||
type: "command",
|
|
||||||
commandType: "file_upload_http",
|
|
||||||
id: agentCommandId,
|
|
||||||
payload: {
|
|
||||||
path: intent.path,
|
|
||||||
url: `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`,
|
|
||||||
total: expectedTotal,
|
|
||||||
},
|
|
||||||
} as any),
|
|
||||||
);
|
|
||||||
logger.debug("[filebrowser] http upload eagerly notified agent", {
|
|
||||||
uploadId,
|
|
||||||
pullId,
|
|
||||||
clientId: intent.clientId,
|
|
||||||
total: expectedTotal,
|
|
||||||
commandId: agentCommandId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug("[filebrowser] http upload eager notify failed", {
|
|
||||||
uploadId,
|
|
||||||
error: (err as Error)?.message || String(err),
|
|
||||||
});
|
|
||||||
agentCommandId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let stagedSize = 0;
|
let stagedSize = 0;
|
||||||
const fileHandle = await fs.open(tmpPath, "w");
|
const fileHandle = await fs.open(tmpPath, "w");
|
||||||
@@ -505,9 +472,9 @@ export async function handleFileDownloadRoutes(
|
|||||||
ok: true,
|
ok: true,
|
||||||
size: stagedSize,
|
size: stagedSize,
|
||||||
path: intent.path,
|
path: intent.path,
|
||||||
pullUrl: `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`,
|
pullUrl,
|
||||||
agentCommandId,
|
agentCommandId: null,
|
||||||
agentNotified: !!agentCommandId,
|
agentNotified: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,52 @@ type WinRERouteDeps = {
|
|||||||
pendingCommandReplies: Map<string, PendingCommandReply>;
|
pendingCommandReplies: Map<string, PendingCommandReply>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WINRE_COMMAND_TIMEOUT_MS = 30 * 60_000;
|
||||||
|
|
||||||
function isWindowsClient(target: any): boolean {
|
function isWindowsClient(target: any): boolean {
|
||||||
const clientOs = (target?.os || "").toLowerCase();
|
const clientOs = (target?.os || "").toLowerCase();
|
||||||
return clientOs.includes("windows");
|
return clientOs.includes("windows");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function waitForWinRECommand(
|
||||||
|
deps: WinRERouteDeps,
|
||||||
|
target: any,
|
||||||
|
clientId: string,
|
||||||
|
command: any,
|
||||||
|
timeoutMessage: string,
|
||||||
|
timeoutMs = WINRE_COMMAND_TIMEOUT_MS,
|
||||||
|
): Promise<{ ok: boolean; message?: string }> {
|
||||||
|
const cmdId = command.id || uuidv4();
|
||||||
|
command.id = cmdId;
|
||||||
|
|
||||||
|
const replyPromise: Promise<{ ok: boolean; message?: string }> = new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
deps.pendingCommandReplies.delete(cmdId);
|
||||||
|
resolve({ ok: false, message: timeoutMessage });
|
||||||
|
}, timeoutMs);
|
||||||
|
deps.pendingCommandReplies.set(cmdId, { resolve, reject, timeout, clientId });
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
target.ws.send(encodeMessage(command));
|
||||||
|
} catch (error) {
|
||||||
|
const pending = deps.pendingCommandReplies.get(cmdId);
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
deps.pendingCommandReplies.delete(cmdId);
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
message: (error as Error)?.message || "Failed to send command",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return replyPromise.catch((error) => ({
|
||||||
|
ok: false,
|
||||||
|
message: (error as Error)?.message || timeoutMessage,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function probeWinRE(deps: WinRERouteDeps, target: any, clientId: string): Promise<{ ok: boolean; reason?: string; message?: string }> {
|
async function probeWinRE(deps: WinRERouteDeps, target: any, clientId: string): Promise<{ ok: boolean; reason?: string; message?: string }> {
|
||||||
const cmdId = uuidv4();
|
const cmdId = uuidv4();
|
||||||
const replyPromise: Promise<{ ok: boolean; message?: string }> = new Promise((resolve, reject) => {
|
const replyPromise: Promise<{ ok: boolean; message?: string }> = new Promise((resolve, reject) => {
|
||||||
@@ -179,26 +220,41 @@ export async function handleWinRERoutes(
|
|||||||
fileName: upload.name,
|
fileName: upload.name,
|
||||||
size: upload.size,
|
size: upload.size,
|
||||||
});
|
});
|
||||||
const pullUrl = `${url.origin}/api/file/upload/pull/${encodeURIComponent(pullId)}`;
|
const pullUrl = `/api/file/upload/pull/${encodeURIComponent(pullId)}`;
|
||||||
|
const uploadResult = await waitForWinRECommand(
|
||||||
target.ws.send(
|
deps,
|
||||||
encodeMessage({
|
target,
|
||||||
|
clientId,
|
||||||
|
{
|
||||||
type: "command",
|
type: "command",
|
||||||
commandType: "file_upload_http" as any,
|
commandType: "file_upload_http" as any,
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
payload: { path: destPath, url: pullUrl, total: upload.size },
|
payload: { path: destPath, url: pullUrl, total: upload.size },
|
||||||
}),
|
},
|
||||||
|
"Upload to client timed out",
|
||||||
);
|
);
|
||||||
|
if (!uploadResult.ok) {
|
||||||
|
results.push({ clientId, ok: false, reason: "upload_failed", error: uploadResult.message || "Upload to client failed" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const cmdId = uuidv4();
|
const installResult = await waitForWinRECommand(
|
||||||
target.ws.send(
|
deps,
|
||||||
encodeMessage({
|
target,
|
||||||
|
clientId,
|
||||||
|
{
|
||||||
type: "command",
|
type: "command",
|
||||||
commandType: "winre_install",
|
commandType: "winre_install",
|
||||||
id: cmdId,
|
id: uuidv4(),
|
||||||
payload: { filePath: destPath },
|
payload: { filePath: destPath },
|
||||||
}),
|
},
|
||||||
|
"WinRE install timed out",
|
||||||
|
5 * 60_000,
|
||||||
);
|
);
|
||||||
|
if (!installResult.ok) {
|
||||||
|
results.push({ clientId, ok: false, reason: "install_failed", error: installResult.message || "WinRE install failed" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
metrics.recordCommand("winre_install");
|
metrics.recordCommand("winre_install");
|
||||||
logAudit({
|
logAudit({
|
||||||
@@ -261,15 +317,23 @@ export async function handleWinRERoutes(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmdId = uuidv4();
|
const installResult = await waitForWinRECommand(
|
||||||
target.ws.send(
|
deps,
|
||||||
encodeMessage({
|
target,
|
||||||
|
clientId,
|
||||||
|
{
|
||||||
type: "command",
|
type: "command",
|
||||||
commandType: "winre_install",
|
commandType: "winre_install",
|
||||||
id: cmdId,
|
id: uuidv4(),
|
||||||
payload: { useSelf: true },
|
payload: { useSelf: true },
|
||||||
}),
|
},
|
||||||
|
"WinRE install timed out",
|
||||||
|
5 * 60_000,
|
||||||
);
|
);
|
||||||
|
if (!installResult.ok) {
|
||||||
|
results.push({ clientId, ok: false, reason: "install_failed", error: installResult.message || "WinRE install failed" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
metrics.recordCommand("winre_install");
|
metrics.recordCommand("winre_install");
|
||||||
logAudit({
|
logAudit({
|
||||||
@@ -332,15 +396,23 @@ export async function handleWinRERoutes(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmdId = uuidv4();
|
const uninstallResult = await waitForWinRECommand(
|
||||||
target.ws.send(
|
deps,
|
||||||
encodeMessage({
|
target,
|
||||||
|
clientId,
|
||||||
|
{
|
||||||
type: "command",
|
type: "command",
|
||||||
commandType: "winre_uninstall",
|
commandType: "winre_uninstall",
|
||||||
id: cmdId,
|
id: uuidv4(),
|
||||||
payload: {},
|
payload: {},
|
||||||
}),
|
},
|
||||||
|
"WinRE uninstall timed out",
|
||||||
|
5 * 60_000,
|
||||||
);
|
);
|
||||||
|
if (!uninstallResult.ok) {
|
||||||
|
results.push({ clientId, ok: false, reason: "uninstall_failed", error: uninstallResult.message || "WinRE uninstall failed" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
metrics.recordCommand("winre_uninstall");
|
metrics.recordCommand("winre_uninstall");
|
||||||
logAudit({
|
logAudit({
|
||||||
|
|||||||
Reference in New Issue
Block a user