Initial
This commit is contained in:
11
README.md
11
README.md
@@ -512,6 +512,7 @@ Payload:
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `GET` | `/api/hosts` | User | List host library. |
|
| `GET` | `/api/hosts` | User | List host library. |
|
||||||
| `POST` | `/api/hosts` | User | Create host. |
|
| `POST` | `/api/hosts` | User | Create host. |
|
||||||
|
| `POST` | `/api/hosts/:id/rdp-session` | User | Create a short-lived browser RDP launch session for a visible Windows host with an assigned visible username/password credential. Returns a same-origin `/rdp/:token` URL. |
|
||||||
| `GET` | `/api/hosts/import/vcenter/status` | User | Return legacy effective vCenter status plus visible saved VMware connection records. |
|
| `GET` | `/api/hosts/import/vcenter/status` | User | Return legacy effective vCenter status plus visible saved VMware connection records. |
|
||||||
| `GET` | `/api/hosts/import/vcenter/connections` | User | List visible saved VMware connections. |
|
| `GET` | `/api/hosts/import/vcenter/connections` | User | List visible saved VMware connections. |
|
||||||
| `POST` | `/api/hosts/import/vcenter/connections` | User | Create a VMware connection record using a Credential Vault `credentialId`. Use `targetType: "vcenter"` for vCenter inventory or `targetType: "host"` for standalone ESXi Web Services inventory. |
|
| `POST` | `/api/hosts/import/vcenter/connections` | User | Create a VMware connection record using a Credential Vault `credentialId`. Use `targetType: "vcenter"` for vCenter inventory or `targetType: "host"` for standalone ESXi Web Services inventory. |
|
||||||
@@ -562,6 +563,15 @@ and `sourceRef` to the vCenter VM id. `osFamily` is normalized to `windows`,
|
|||||||
infer it from inventory. vCenter imports infer Windows/Linux from VMware Tools
|
infer it from inventory. vCenter imports infer Windows/Linux from VMware Tools
|
||||||
guest identity when available; unknown guests are imported as `other`.
|
guest identity when available; unknown guests are imported as `other`.
|
||||||
|
|
||||||
|
Windows hosts with an assigned visible username/password Credential Vault entry
|
||||||
|
show an RDP icon in the Hosts table. Clicking it calls
|
||||||
|
`POST /api/hosts/:id/rdp-session`, opens a same-origin `/rdp/:token`
|
||||||
|
tab, and renders a browser RDP canvas through `mstsc.js`. The browser receives
|
||||||
|
only a short-lived opaque launch token; POSHManager resolves the host address
|
||||||
|
and decrypts the assigned credential server-side. The RDP gateway uses the
|
||||||
|
default RDP port `3389`; PowerShell remoting `port` metadata remains separate
|
||||||
|
from RDP launch behavior.
|
||||||
|
|
||||||
Host Groups let operators aggregate manual, imported, and VMware-sourced hosts
|
Host Groups let operators aggregate manual, imported, and VMware-sourced hosts
|
||||||
without duplicating RunPlans. Manual groups store an explicit list of visible
|
without duplicating RunPlans. Manual groups store an explicit list of visible
|
||||||
host IDs that an operator can add/remove in the Hosts screen. Dynamic groups
|
host IDs that an operator can add/remove in the Hosts screen. Dynamic groups
|
||||||
@@ -1379,6 +1389,7 @@ on the target platform before broad execution.
|
|||||||
|
|
||||||
- SQLite access uses prepared statements through `node:sqlite`.
|
- SQLite access uses prepared statements through `node:sqlite`.
|
||||||
- Credential secrets are encrypted with AES-256-GCM and are not returned by API reads.
|
- Credential secrets are encrypted with AES-256-GCM and are not returned by API reads.
|
||||||
|
- Browser RDP sessions use `mstsc.js` with short-lived POSHManager launch tokens; host passwords stay in the API process and are never embedded in the Vue app or launch URL. The upstream `mstsc.js` dependency is legacy and currently brings transitive npm audit findings, so expose `/rdp` only through authenticated POSHManager/reverse-proxy paths and restrict network access to trusted operators.
|
||||||
- Request logs redact authorization and cookie headers.
|
- Request logs redact authorization and cookie headers.
|
||||||
- Protected routes use JWT auth.
|
- Protected routes use JWT auth.
|
||||||
- Admin-only APIs are guarded by `requireAdmin`.
|
- Admin-only APIs are guarded by `requireAdmin`.
|
||||||
|
|||||||
@@ -336,7 +336,30 @@
|
|||||||
<template #cell-sourceType="{ row }"><span :class="['status-pill', row.sourceType]">{{ row.sourceType === 'vmware' ? 'VMware' : 'Manual' }}</span></template>
|
<template #cell-sourceType="{ row }"><span :class="['status-pill', row.sourceType]">{{ row.sourceType === 'vmware' ? 'VMware' : 'Manual' }}</span></template>
|
||||||
<template #cell-credentialName="{ row }">{{ row.credentialName || 'No credential' }}</template>
|
<template #cell-credentialName="{ row }">{{ row.credentialName || 'No credential' }}</template>
|
||||||
<template #cell-tags="{ row }"><span class="tag-row">{{ row.tags?.join(', ') || '-' }}</span></template>
|
<template #cell-tags="{ row }"><span class="tag-row">{{ row.tags?.join(', ') || '-' }}</span></template>
|
||||||
<template #cell-actions="{ row }"><button class="ghost-button compact" type="button" @click="openHostModal(row)">Edit</button></template>
|
<template #cell-actions="{ row }">
|
||||||
|
<div class="host-row-actions">
|
||||||
|
<button
|
||||||
|
v-if="isWindowsHost(row)"
|
||||||
|
class="ghost-button compact icon-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canLaunchRdp(row)"
|
||||||
|
:aria-label="rdpActionTitle(row)"
|
||||||
|
:title="rdpActionTitle(row)"
|
||||||
|
@click="launchRdpHost(row)"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-remote-desktop" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ghost-button compact icon-button"
|
||||||
|
type="button"
|
||||||
|
aria-label="Edit host"
|
||||||
|
title="Edit host"
|
||||||
|
@click="openHostModal(row)"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</ResourceTable>
|
</ResourceTable>
|
||||||
|
|
||||||
<HostGroupsPanel
|
<HostGroupsPanel
|
||||||
@@ -2592,6 +2615,91 @@ function osFamilyLabel(value) {
|
|||||||
return 'Other';
|
return 'Other';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWindowsHost(host) {
|
||||||
|
return (host?.osFamily || '').toLowerCase() === 'windows';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canLaunchRdp(host) {
|
||||||
|
return isWindowsHost(host) && Boolean(host?.credentialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rdpActionTitle(host) {
|
||||||
|
if (!isWindowsHost(host)) return 'RDP is available for Windows hosts only';
|
||||||
|
if (!host?.credentialId) return 'Assign a host credential before launching RDP';
|
||||||
|
return `Open RDP session to ${host.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRdpLaunchPopup(popup, hostName, state = 'loading', message = '') {
|
||||||
|
if (!popup?.document) return;
|
||||||
|
const isError = state === 'error';
|
||||||
|
popup.document.open();
|
||||||
|
popup.document.write(`<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>${escapePreviewHtml(isError ? 'RDP launch failed' : `Opening RDP - ${hostName}`)}</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: #eff7ff;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 14%, rgba(109, 102, 255, .28), transparent 32%),
|
||||||
|
radial-gradient(circle at 78% 16%, rgba(88, 221, 255, .22), transparent 32%),
|
||||||
|
linear-gradient(135deg, #070b18, #101a32 48%, #050814);
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
width: min(560px, 100%);
|
||||||
|
border: 1px solid rgba(156, 199, 232, .24);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,.11), rgba(255,255,255,.055)), rgba(8, 14, 31, .72);
|
||||||
|
box-shadow: 0 28px 100px rgba(0, 0, 0, .42), inset 0 1px 0 rgba(255,255,255,.14);
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
.eyebrow { color: ${isError ? '#ff9aa8' : '#58ddff'}; font-size: 11px; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; }
|
||||||
|
h1 { margin: 8px 0; font-size: clamp(28px, 4vw, 42px); line-height: 1; }
|
||||||
|
p { margin: 0; color: rgba(225, 236, 251, .72); line-height: 1.6; }
|
||||||
|
.status { margin-top: 20px; display: flex; align-items: center; gap: 10px; font-weight: 800; }
|
||||||
|
.pulse { width: 10px; height: 10px; border-radius: 50%; background: ${isError ? '#ff7d91' : '#64e7bd'}; box-shadow: 0 0 22px currentColor; color: ${isError ? '#ff7d91' : '#64e7bd'}; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section>
|
||||||
|
<span class="eyebrow">${isError ? 'POSHManager RDP error' : 'POSHManager RDP'}</span>
|
||||||
|
<h1>${escapePreviewHtml(hostName)}</h1>
|
||||||
|
<p>${escapePreviewHtml(message || (isError ? 'The RDP launch failed.' : 'Preparing the browser RDP session...'))}</p>
|
||||||
|
<div class="status"><span class="pulse"></span><span>${isError ? 'Launch failed' : 'Creating secure launch token...'}</span></div>
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
</html>`);
|
||||||
|
popup.document.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchRdpHost(host) {
|
||||||
|
if (!canLaunchRdp(host)) return;
|
||||||
|
const popup = window.open('about:blank', '_blank');
|
||||||
|
writeRdpLaunchPopup(popup, host.name, 'loading');
|
||||||
|
try {
|
||||||
|
const result = await api.post(`/api/hosts/${host.id}/rdp-session`, {});
|
||||||
|
const launchUrl = new URL(result.url, window.location.origin).toString();
|
||||||
|
if (popup) {
|
||||||
|
popup.location.replace(launchUrl);
|
||||||
|
} else {
|
||||||
|
window.open(launchUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
notify(`Opening RDP session to ${host.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
writeRdpLaunchPopup(popup, host.name, 'error', err.message);
|
||||||
|
notify(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveHostGroup(payload) {
|
async function saveHostGroup(payload) {
|
||||||
const body = { ...payload };
|
const body = { ...payload };
|
||||||
const groupId = body.id;
|
const groupId = body.id;
|
||||||
|
|||||||
@@ -506,6 +506,29 @@ textarea:focus {
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 38px;
|
||||||
|
min-width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button i {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-row-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
|
|||||||
@@ -94,11 +94,23 @@
|
|||||||
<template #cell-latestAt="{ row }">{{ shortDate(row.latestAt) }}</template>
|
<template #cell-latestAt="{ row }">{{ shortDate(row.latestAt) }}</template>
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="job-output-row-actions">
|
<div class="job-output-row-actions">
|
||||||
<button class="ghost-button compact" type="button" @click="viewGroup(row)">
|
<button
|
||||||
<i class="mdi mdi-eye-outline" aria-hidden="true"></i>View
|
class="ghost-button compact icon-only"
|
||||||
|
type="button"
|
||||||
|
aria-label="View selected output artifact"
|
||||||
|
title="View selected output artifact"
|
||||||
|
@click="viewGroup(row)"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-eye-outline" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="ghost-button compact" type="button" @click="downloadGroup(row)">
|
<button
|
||||||
<i class="mdi mdi-download-outline" aria-hidden="true"></i>Download
|
class="ghost-button compact icon-only"
|
||||||
|
type="button"
|
||||||
|
aria-label="Download selected output artifact"
|
||||||
|
title="Download selected output artifact"
|
||||||
|
@click="downloadGroup(row)"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-download-outline" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -405,12 +417,12 @@ function formatBytes(bytes = 0) {
|
|||||||
|
|
||||||
.job-output-list :deep(th:nth-child(1)),
|
.job-output-list :deep(th:nth-child(1)),
|
||||||
.job-output-list :deep(td:nth-child(1)) {
|
.job-output-list :deep(td:nth-child(1)) {
|
||||||
width: 30%;
|
width: 32%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-output-list :deep(th:nth-child(2)),
|
.job-output-list :deep(th:nth-child(2)),
|
||||||
.job-output-list :deep(td:nth-child(2)) {
|
.job-output-list :deep(td:nth-child(2)) {
|
||||||
width: 22%;
|
width: 23%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-output-list :deep(th:nth-child(3)),
|
.job-output-list :deep(th:nth-child(3)),
|
||||||
@@ -425,12 +437,12 @@ function formatBytes(bytes = 0) {
|
|||||||
|
|
||||||
.job-output-list :deep(th:nth-child(5)),
|
.job-output-list :deep(th:nth-child(5)),
|
||||||
.job-output-list :deep(td:nth-child(5)) {
|
.job-output-list :deep(td:nth-child(5)) {
|
||||||
width: 14%;
|
width: 15%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-output-list :deep(th:last-child),
|
.job-output-list :deep(th:last-child),
|
||||||
.job-output-list :deep(td:last-child) {
|
.job-output-list :deep(td:last-child) {
|
||||||
width: 13%;
|
width: 9%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-output-title {
|
.job-output-title {
|
||||||
@@ -533,16 +545,24 @@ function formatBytes(bytes = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.job-output-row-actions {
|
.job-output-row-actions {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.job-output-row-actions .ghost-button {
|
.job-output-row-actions .icon-only {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 0;
|
min-width: 38px;
|
||||||
padding-inline: 10px;
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-output-row-actions .icon-only i {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
|||||||
820
package-lock.json
generated
820
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@
|
|||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"monaco-editor": "^0.53.0",
|
"monaco-editor": "^0.53.0",
|
||||||
|
"mstsc.js": "^0.2.4",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
updateVCenterConnection
|
updateVCenterConnection
|
||||||
} from '../models/vcenterConnectionModel.js';
|
} from '../models/vcenterConnectionModel.js';
|
||||||
import { buildHostPayloadFromVm, previewVCenterHosts, testVCenterConnection, vcenterStatus } from '../services/vcenterService.js';
|
import { buildHostPayloadFromVm, previewVCenterHosts, testVCenterConnection, vcenterStatus } from '../services/vcenterService.js';
|
||||||
|
import { createRdpLaunchSession } from '../services/rdpGatewayService.js';
|
||||||
|
|
||||||
export function index(req, res) {
|
export function index(req, res) {
|
||||||
res.json(listHosts(req.user.id));
|
res.json(listHosts(req.user.id));
|
||||||
@@ -32,6 +33,14 @@ export function destroy(req, res) {
|
|||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createRdpSession(req, res) {
|
||||||
|
try {
|
||||||
|
res.status(201).json(createRdpLaunchSession(req.params.id, req.user.id));
|
||||||
|
} catch (error) {
|
||||||
|
res.status(error.statusCode || 400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function listGroups(req, res) {
|
export function listGroups(req, res) {
|
||||||
res.json(listHostGroups(req.user.id));
|
res.json(listHostGroups(req.user.id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const operationsArticles = [
|
|||||||
'Create credentials in Credential Vault first, then attach them to hosts. Secrets are encrypted using AES-256-GCM.',
|
'Create credentials in Credential Vault first, then attach them to hosts. Secrets are encrypted using AES-256-GCM.',
|
||||||
'Hosts support WinRM, SSH, local, and API transport metadata. PowerShell remoting and firewall prerequisites must be configured outside POSHManager.',
|
'Hosts support WinRM, SSH, local, and API transport metadata. PowerShell remoting and firewall prerequisites must be configured outside POSHManager.',
|
||||||
'Each host tracks an OS type of Windows, Linux, or Other. vCenter imports infer this from VMware Tools when possible; manual hosts should set it during add/edit.',
|
'Each host tracks an OS type of Windows, Linux, or Other. vCenter imports infer this from VMware Tools when possible; manual hosts should set it during add/edit.',
|
||||||
|
'Windows hosts with an assigned username/password Credential Vault entry show an RDP icon. The icon opens a short-lived /rdp/:token URL in a new tab and renders a mstsc.js browser RDP canvas while the API keeps the real password server-side.',
|
||||||
'When Linux targets are selected, POSHManager checks scripts for common Windows-only PowerShell patterns such as registry providers, C:\\ paths, WMI/Win32 classes, Windows executables, and risky alias usage.'
|
'When Linux targets are selected, POSHManager checks scripts for common Windows-only PowerShell patterns such as registry providers, C:\\ paths, WMI/Win32 classes, Windows executables, and risky alias usage.'
|
||||||
]),
|
]),
|
||||||
section('Host Groups and VMware sources', [
|
section('Host Groups and VMware sources', [
|
||||||
@@ -134,6 +135,7 @@ const apiArticles = [
|
|||||||
article('api-hosts-credentials-runplans', 'API', 'Hosts, Credentials, RunPlans, Jobs, And Logs API', 'Manage execution targets and inspect results through the API.', [
|
article('api-hosts-credentials-runplans', 'API', 'Hosts, Credentials, RunPlans, Jobs, And Logs API', 'Manage execution targets and inspect results through the API.', [
|
||||||
apiExample('Post', '/api/credentials', 'Create an encrypted username/password credential.', '@{ name = "WinRM Admin"; kind = "username_password"; username = "CONTOSO\\svc-posh"; secret = "change-me"; visibility = "personal" }'),
|
apiExample('Post', '/api/credentials', 'Create an encrypted username/password credential.', '@{ name = "WinRM Admin"; kind = "username_password"; username = "CONTOSO\\svc-posh"; secret = "change-me"; visibility = "personal" }'),
|
||||||
apiExample('Post', '/api/hosts', 'Create a managed host.', '@{ name = "APP01"; address = "app01.contoso.local"; fqdn = "app01.contoso.local"; osFamily = "windows"; transport = "winrm"; port = 5986; credentialId = $null; tags = @("prod","web"); notes = "" }'),
|
apiExample('Post', '/api/hosts', 'Create a managed host.', '@{ name = "APP01"; address = "app01.contoso.local"; fqdn = "app01.contoso.local"; osFamily = "windows"; transport = "winrm"; port = 5986; credentialId = $null; tags = @("prod","web"); notes = "" }'),
|
||||||
|
apiExample('Post', '/api/hosts/hst_123/rdp-session', 'Create a short-lived browser RDP launch URL for a visible Windows host with an assigned visible username/password credential.', '$session = Invoke-RestMethod -Method Post -Uri "$base/api/hosts/hst_123/rdp-session" -Headers $headers\nStart-Process "$base$($session.url)"'),
|
||||||
apiExample('Post', '/api/scripts/scr_123/compatibility', 'Analyze a script against direct host and Host Group targets before executing against Linux hosts.', '@{ hostIds = @("hst_linux_01"); hostGroupIds = @("hg_mixed_targets") }'),
|
apiExample('Post', '/api/scripts/scr_123/compatibility', 'Analyze a script against direct host and Host Group targets before executing against Linux hosts.', '@{ hostIds = @("hst_linux_01"); hostGroupIds = @("hg_mixed_targets") }'),
|
||||||
apiExample('Get', '/api/runplans/rp_123/compatibility', 'Analyze a RunPlan script against its current target OS mix before execution.'),
|
apiExample('Get', '/api/runplans/rp_123/compatibility', 'Analyze a RunPlan script against its current target OS mix before execution.'),
|
||||||
apiExample('Post', '/api/runplans', 'Create a RunPlan. hostIds should contain existing host ids.', '@{ name = "Patch Validation"; description = "Run validation script"; scriptId = "scr_123"; visibility = "shared"; parallel = $true; hostIds = @("hst_123") }'),
|
apiExample('Post', '/api/runplans', 'Create a RunPlan. hostIds should contain existing host ids.', '@{ name = "Patch Validation"; description = "Run validation script"; scriptId = "scr_123"; visibility = "shared"; parallel = $true; hostIds = @("hst_123") }'),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { startCatalogWorker } from './services/catalogWorker.js';
|
|||||||
import { startPromotionWorker } from './services/promotionWorker.js';
|
import { startPromotionWorker } from './services/promotionWorker.js';
|
||||||
import { startHostGroupWorker } from './services/hostGroupWorker.js';
|
import { startHostGroupWorker } from './services/hostGroupWorker.js';
|
||||||
import { startRunPlanScheduleWorker } from './services/runPlanScheduleWorker.js';
|
import { startRunPlanScheduleWorker } from './services/runPlanScheduleWorker.js';
|
||||||
|
import { mountRdpGateway, startRdpGateway } from './services/rdpGatewayService.js';
|
||||||
|
|
||||||
// Fail fast in production rather than ship with well-known default secrets.
|
// Fail fast in production rather than ship with well-known default secrets.
|
||||||
// These defaults are fine for local development but are publicly known, so a
|
// These defaults are fine for local development but are publicly known, so a
|
||||||
@@ -55,14 +56,16 @@ app.use(cors({
|
|||||||
app.use(express.json({ limit: config.jsonLimit }));
|
app.use(express.json({ limit: config.jsonLimit }));
|
||||||
app.use(requestLogger);
|
app.use(requestLogger);
|
||||||
app.use('/api', apiRoutes);
|
app.use('/api', apiRoutes);
|
||||||
|
mountRdpGateway(app);
|
||||||
|
|
||||||
app.use((error, req, res, next) => {
|
app.use((error, req, res, next) => {
|
||||||
logger.error('unhandled error', { error });
|
logger.error('unhandled error', { error });
|
||||||
res.status(500).json({ error: 'Unexpected server error' });
|
res.status(500).json({ error: 'Unexpected server error' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(config.port, () => {
|
const httpServer = app.listen(config.port, () => {
|
||||||
logger.info(`POSHManager API listening on ${config.port}`);
|
logger.info(`POSHManager API listening on ${config.port}`);
|
||||||
|
startRdpGateway(httpServer);
|
||||||
startCatalogWorker();
|
startCatalogWorker();
|
||||||
startPromotionWorker();
|
startPromotionWorker();
|
||||||
startHostGroupWorker();
|
startHostGroupWorker();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
|||||||
import {
|
import {
|
||||||
create,
|
create,
|
||||||
createGroup,
|
createGroup,
|
||||||
|
createRdpSession,
|
||||||
createVCenterConnectionRecord,
|
createVCenterConnectionRecord,
|
||||||
deleteGroup,
|
deleteGroup,
|
||||||
deleteVCenterConnectionRecord,
|
deleteVCenterConnectionRecord,
|
||||||
@@ -39,5 +40,6 @@ hostRoutes.delete('/groups/:groupId', requireAuth, deleteGroup);
|
|||||||
hostRoutes.post('/groups/:groupId/sync', requireAuth, syncGroup);
|
hostRoutes.post('/groups/:groupId/sync', requireAuth, syncGroup);
|
||||||
hostRoutes.get('/', requireAuth, index);
|
hostRoutes.get('/', requireAuth, index);
|
||||||
hostRoutes.post('/', requireAuth, create);
|
hostRoutes.post('/', requireAuth, create);
|
||||||
|
hostRoutes.post('/:id/rdp-session', requireAuth, createRdpSession);
|
||||||
hostRoutes.put('/:id', requireAuth, update);
|
hostRoutes.put('/:id', requireAuth, update);
|
||||||
hostRoutes.delete('/:id', requireAuth, destroy);
|
hostRoutes.delete('/:id', requireAuth, destroy);
|
||||||
|
|||||||
300
server/services/rdpGatewayService.js
Normal file
300
server/services/rdpGatewayService.js
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { path } from '../utils/pathUtils.js';
|
||||||
|
import express from 'express';
|
||||||
|
import { db, visibleClause } from '../db.js';
|
||||||
|
import { decryptSecret } from './cryptoStore.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
import { normalizeOsFamily } from '../utils/osFamily.js';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const rdp = require('node-rdpjs');
|
||||||
|
const socketIo = require('socket.io');
|
||||||
|
const mstscPackagePath = require.resolve('mstsc.js/package.json');
|
||||||
|
const mstscClientDir = path.join(path.dirname(mstscPackagePath), 'client');
|
||||||
|
const SESSION_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const sessions = new Map();
|
||||||
|
let socketServer = null;
|
||||||
|
|
||||||
|
function token() {
|
||||||
|
return randomBytes(24).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value = '') {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitDomainUsername(username = '') {
|
||||||
|
const value = String(username || '');
|
||||||
|
const match = value.match(/^([^\\]+)\\(.+)$/);
|
||||||
|
if (!match) return { domain: '', username: value };
|
||||||
|
return { domain: match[1], username: match[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupExpiredSessions() {
|
||||||
|
const cutoff = Date.now() - SESSION_TTL_MS;
|
||||||
|
for (const [sessionToken, session] of sessions.entries()) {
|
||||||
|
if (session.createdAt < cutoff) sessions.delete(sessionToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRdpTarget(hostId, userId) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
|
||||||
|
c.secret_cipher, c.secret_iv, c.secret_tag
|
||||||
|
FROM hosts h
|
||||||
|
JOIN credentials c ON c.id = h.credential_id
|
||||||
|
WHERE h.id = ?
|
||||||
|
AND ${visibleClause('h')}
|
||||||
|
AND ${visibleClause('c')}
|
||||||
|
`).get(hostId, userId, userId, userId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRdpLaunchSession(hostId, userId) {
|
||||||
|
cleanupExpiredSessions();
|
||||||
|
const target = loadRdpTarget(hostId, userId);
|
||||||
|
if (!target) {
|
||||||
|
const error = new Error('Windows host with an assigned visible credential is required for RDP launch.');
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (normalizeOsFamily(target.os_family, 'other') !== 'windows') {
|
||||||
|
const error = new Error('RDP launch is only available for Windows hosts.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (target.credential_kind !== 'username_password') {
|
||||||
|
const error = new Error('RDP launch requires a username/password credential assigned to the host.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = decryptSecret(target);
|
||||||
|
if (!password) {
|
||||||
|
const error = new Error('Assigned host credential does not have a decryptable password.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionToken = token();
|
||||||
|
const parsedUsername = splitDomainUsername(target.credential_username);
|
||||||
|
const session = {
|
||||||
|
token: sessionToken,
|
||||||
|
userId,
|
||||||
|
hostId: target.id,
|
||||||
|
hostName: target.name,
|
||||||
|
address: target.fqdn || target.address,
|
||||||
|
port: 3389,
|
||||||
|
domain: parsedUsername.domain,
|
||||||
|
username: parsedUsername.username,
|
||||||
|
password,
|
||||||
|
credentialName: target.credential_name,
|
||||||
|
createdAt: Date.now()
|
||||||
|
};
|
||||||
|
sessions.set(sessionToken, session);
|
||||||
|
logger.info('rdp session created', {
|
||||||
|
hostId: target.id,
|
||||||
|
hostName: target.name,
|
||||||
|
userId,
|
||||||
|
credentialId: target.credential_id
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
token: sessionToken,
|
||||||
|
url: `/rdp/${sessionToken}`,
|
||||||
|
expiresInSeconds: Math.floor(SESSION_TTL_MS / 1000),
|
||||||
|
host: {
|
||||||
|
id: target.id,
|
||||||
|
name: target.name,
|
||||||
|
address: session.address
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessionPage(session) {
|
||||||
|
const safeToken = escapeHtml(session.token);
|
||||||
|
const safeHost = escapeHtml(session.hostName);
|
||||||
|
const safeAddress = escapeHtml(session.address);
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>RDP - ${safeHost}</title>
|
||||||
|
<link rel="icon" href="/rdp/assets/img/favicon.ico" />
|
||||||
|
<script src="/rdp/socket.io/socket.io.js"></script>
|
||||||
|
<script src="/rdp/assets/js/mstsc.js"></script>
|
||||||
|
<script src="/rdp/assets/js/keyboard.js"></script>
|
||||||
|
<script src="/rdp/assets/js/rle.js"></script>
|
||||||
|
<script src="/rdp/assets/js/client.js"></script>
|
||||||
|
<script src="/rdp/assets/js/canvas.js"></script>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #eff7ff;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 12%, rgba(109, 102, 255, .25), transparent 34%),
|
||||||
|
radial-gradient(circle at 78% 18%, rgba(88, 221, 255, .22), transparent 32%),
|
||||||
|
linear-gradient(135deg, #090d1e, #101a32 48%, #050814);
|
||||||
|
}
|
||||||
|
.launch {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
width: min(560px, 100%);
|
||||||
|
border: 1px solid rgba(156, 199, 232, .24);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,.11), rgba(255,255,255,.055)), rgba(8, 14, 31, .72);
|
||||||
|
box-shadow: 0 28px 100px rgba(0, 0, 0, .42), inset 0 1px 0 rgba(255,255,255,.14);
|
||||||
|
padding: 28px;
|
||||||
|
backdrop-filter: blur(24px) saturate(140%);
|
||||||
|
}
|
||||||
|
.eyebrow { color: #58ddff; font-size: 11px; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; }
|
||||||
|
h1 { margin: 8px 0 8px; font-size: clamp(28px, 4vw, 44px); line-height: 1; }
|
||||||
|
p { margin: 0; color: rgba(225, 236, 251, .72); line-height: 1.6; }
|
||||||
|
.status { margin-top: 20px; display: flex; align-items: center; gap: 10px; font-weight: 800; }
|
||||||
|
.pulse { width: 10px; height: 10px; border-radius: 50%; background: #64e7bd; box-shadow: 0 0 22px #64e7bd; }
|
||||||
|
#rdpCanvas { display: none; width: 100vw; height: 100vh; background: #050814; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="main" class="launch">
|
||||||
|
<section class="panel">
|
||||||
|
<span class="eyebrow">POSHManager RDP</span>
|
||||||
|
<h1>${safeHost}</h1>
|
||||||
|
<p>Opening a browser RDP session to ${safeAddress} using the assigned host credential. Close this tab to end the session.</p>
|
||||||
|
<div class="status"><span class="pulse"></span><span id="status">Preparing secure session...</span></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<canvas id="rdpCanvas"></canvas>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try { window.opener = null; } catch (err) {}
|
||||||
|
var token = "${safeToken}";
|
||||||
|
var status = document.getElementById('status');
|
||||||
|
var canvas = document.getElementById('rdpCanvas');
|
||||||
|
var main = document.getElementById('main');
|
||||||
|
var client = Mstsc.client.create(canvas);
|
||||||
|
function resize() {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
resize();
|
||||||
|
status.textContent = 'Connecting...';
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
main.style.display = 'none';
|
||||||
|
client.connect('poshmanager-session', '', '', token, function (err) {
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
main.style.display = 'grid';
|
||||||
|
status.textContent = err ? 'RDP session failed or closed.' : 'RDP session closed.';
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountRdpGateway(app) {
|
||||||
|
app.use('/rdp/assets', express.static(mstscClientDir, {
|
||||||
|
fallthrough: false,
|
||||||
|
immutable: true,
|
||||||
|
maxAge: '1h'
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/rdp/sessions/:token', (req, res) => {
|
||||||
|
res.redirect(302, `/rdp/${encodeURIComponent(req.params.token)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/rdp/:token', (req, res) => {
|
||||||
|
cleanupExpiredSessions();
|
||||||
|
const session = sessions.get(req.params.token);
|
||||||
|
if (!session) return res.status(404).send('RDP session expired or not found.');
|
||||||
|
res.type('html').send(renderSessionPage(session));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startRdpGateway(httpServer) {
|
||||||
|
if (socketServer) return socketServer;
|
||||||
|
socketServer = socketIo(httpServer, { path: '/rdp/socket.io' });
|
||||||
|
socketServer.on('connection', (client) => {
|
||||||
|
let rdpClient = null;
|
||||||
|
|
||||||
|
client.on('infos', (infos = {}) => {
|
||||||
|
cleanupExpiredSessions();
|
||||||
|
const sessionToken = infos.token || infos.sessionToken || infos.password;
|
||||||
|
const session = sessions.get(sessionToken);
|
||||||
|
if (!session) {
|
||||||
|
client.emit('rdp-error', { code: 'POSHM_RDP_SESSION', message: 'RDP session expired or invalid.' });
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rdpClient) rdpClient.close();
|
||||||
|
const screen = {
|
||||||
|
width: Math.max(640, Math.min(Number(infos.screen?.width || 1280), 3840)),
|
||||||
|
height: Math.max(480, Math.min(Number(infos.screen?.height || 800), 2160))
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('rdp connection starting', {
|
||||||
|
hostId: session.hostId,
|
||||||
|
hostName: session.hostName,
|
||||||
|
address: session.address,
|
||||||
|
userId: session.userId
|
||||||
|
});
|
||||||
|
|
||||||
|
rdpClient = rdp.createClient({
|
||||||
|
domain: session.domain,
|
||||||
|
userName: session.username,
|
||||||
|
password: session.password,
|
||||||
|
enablePerf: true,
|
||||||
|
autoLogin: true,
|
||||||
|
screen,
|
||||||
|
locale: infos.locale,
|
||||||
|
logLevel: 'ERROR'
|
||||||
|
}).on('connect', () => {
|
||||||
|
client.emit('rdp-connect');
|
||||||
|
logger.info('rdp connection established', { hostId: session.hostId, hostName: session.hostName, userId: session.userId });
|
||||||
|
}).on('bitmap', (bitmap) => {
|
||||||
|
client.emit('rdp-bitmap', bitmap);
|
||||||
|
}).on('close', () => {
|
||||||
|
client.emit('rdp-close');
|
||||||
|
sessions.delete(sessionToken);
|
||||||
|
logger.info('rdp connection closed', { hostId: session.hostId, hostName: session.hostName, userId: session.userId });
|
||||||
|
}).on('error', (err) => {
|
||||||
|
client.emit('rdp-error', { code: err.code || 'RDP_ERROR', message: err.message || String(err) });
|
||||||
|
logger.error('rdp connection failed', { hostId: session.hostId, hostName: session.hostName, userId: session.userId, error: err.message || String(err) });
|
||||||
|
}).connect(session.address, session.port);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('mouse', (x, y, button, isPressed) => {
|
||||||
|
if (rdpClient) rdpClient.sendPointerEvent(x, y, button, isPressed);
|
||||||
|
});
|
||||||
|
client.on('wheel', (x, y, step, isNegative, isHorizontal) => {
|
||||||
|
if (rdpClient) rdpClient.sendWheelEvent(x, y, step, isNegative, isHorizontal);
|
||||||
|
});
|
||||||
|
client.on('scancode', (code, isPressed) => {
|
||||||
|
if (rdpClient) rdpClient.sendKeyEventScancode(code, isPressed);
|
||||||
|
});
|
||||||
|
client.on('unicode', (code, isPressed) => {
|
||||||
|
if (rdpClient) rdpClient.sendKeyEventUnicode(code, isPressed);
|
||||||
|
});
|
||||||
|
client.on('disconnect', () => {
|
||||||
|
if (rdpClient) rdpClient.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
logger.info('POSHManager RDP gateway mounted at /rdp');
|
||||||
|
return socketServer;
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
const { createHost, listHosts, updateHost, deleteHost, filterVisibleHostIds } = await load('../models/hostModel.js');
|
const { createHost, listHosts, updateHost, deleteHost, filterVisibleHostIds } = await load('../models/hostModel.js');
|
||||||
|
const { createCredential } = await load('../models/credentialModel.js');
|
||||||
const { createHostGroup, listHostGroups, syncHostGroup } = await load('../models/hostGroupModel.js');
|
const { createHostGroup, listHostGroups, syncHostGroup } = await load('../models/hostGroupModel.js');
|
||||||
const { createRunPlan, loadRunPlan } = await load('../models/runPlanModel.js');
|
const { createRunPlan, loadRunPlan } = await load('../models/runPlanModel.js');
|
||||||
|
const { createRdpLaunchSession } = await load('../services/rdpGatewayService.js');
|
||||||
const { db } = await load('../db.js');
|
const { db } = await load('../db.js');
|
||||||
|
|
||||||
const owner = adminUserId();
|
const owner = adminUserId();
|
||||||
@@ -52,6 +54,46 @@ test('filterVisibleHostIds drops hosts the user cannot see', () => {
|
|||||||
assert.deepEqual(visibleToOther, [shared.id]);
|
assert.deepEqual(visibleToOther, [shared.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('RDP launch creates a short-lived browser session for Windows hosts with visible credentials', () => {
|
||||||
|
const credential = createCredential({
|
||||||
|
name: 'RDP Admin',
|
||||||
|
kind: 'username_password',
|
||||||
|
username: 'CONTOSO\\rdpadmin',
|
||||||
|
secret: 'rdp-secret',
|
||||||
|
visibility: 'shared'
|
||||||
|
}, owner);
|
||||||
|
const host = createHost({
|
||||||
|
name: 'RDP-WIN-01',
|
||||||
|
address: 'rdp-win-01.contoso.local',
|
||||||
|
fqdn: 'rdp-win-01.contoso.local',
|
||||||
|
osFamily: 'windows',
|
||||||
|
transport: 'winrm',
|
||||||
|
credentialId: credential.id,
|
||||||
|
tags: [],
|
||||||
|
visibility: 'shared'
|
||||||
|
}, owner);
|
||||||
|
const session = createRdpLaunchSession(host.id, other);
|
||||||
|
assert.match(session.url, /^\/rdp\/[^/]+$/);
|
||||||
|
assert.equal(session.host.name, 'RDP-WIN-01');
|
||||||
|
assert.equal(session.host.address, 'rdp-win-01.contoso.local');
|
||||||
|
assert.ok(!session.url.includes('rdp-secret'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('RDP launch rejects non-Windows hosts or hosts without credentials', () => {
|
||||||
|
const credential = createCredential({
|
||||||
|
name: 'RDP Linux Credential',
|
||||||
|
kind: 'username_password',
|
||||||
|
username: 'linux-user',
|
||||||
|
secret: 'linux-secret',
|
||||||
|
visibility: 'shared'
|
||||||
|
}, owner);
|
||||||
|
const linux = createHost({ name: 'RDP-LINUX-01', address: 'rdp-linux.local', osFamily: 'linux', transport: 'ssh', credentialId: credential.id, tags: [], visibility: 'shared' }, owner);
|
||||||
|
assert.throws(() => createRdpLaunchSession(linux.id, owner), /Windows hosts/i);
|
||||||
|
|
||||||
|
const noCredential = createHost({ name: 'RDP-NOCRED-01', address: 'rdp-nocred.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
|
||||||
|
assert.throws(() => createRdpLaunchSession(noCredential.id, owner), /assigned visible credential/i);
|
||||||
|
});
|
||||||
|
|
||||||
test('manual host groups keep explicit members', () => {
|
test('manual host groups keep explicit members', () => {
|
||||||
const host = createHost({ name: 'ManualMember', address: 'manual-member.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
|
const host = createHost({ name: 'ManualMember', address: 'manual-member.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
|
||||||
const group = createHostGroup({ name: 'Manual Group', mode: 'manual', hostIds: [host.id], visibility: 'shared' }, owner);
|
const group = createHostGroup({ name: 'Manual Group', mode: 'manual', hostIds: [host.id], visibility: 'shared' }, owner);
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export default defineConfig({
|
|||||||
target: apiTarget,
|
target: apiTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false
|
secure: false
|
||||||
|
},
|
||||||
|
'/rdp': {
|
||||||
|
target: apiTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
ws: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -31,6 +37,12 @@ export default defineConfig({
|
|||||||
target: apiTarget,
|
target: apiTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false
|
secure: false
|
||||||
|
},
|
||||||
|
'/rdp': {
|
||||||
|
target: apiTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
ws: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user