diff --git a/.icons/desktop.svg b/.icons/desktop.svg
new file mode 100644
index 00000000..77d231ce
--- /dev/null
+++ b/.icons/desktop.svg
@@ -0,0 +1,5 @@
+
diff --git a/package-lock.json b/package-lock.json
index 8039c1cf..10109422 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,9 +18,9 @@
}
},
"node_modules/@types/node": {
- "version": "20.11.30",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz",
- "integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==",
+ "version": "20.12.14",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz",
+ "integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
@@ -45,12 +45,12 @@
}
},
"node_modules/bun-types": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.4.tgz",
- "integrity": "sha512-E1kk0FNpxpkSSlCVXEa4HfyhSUEpKtCFrybPVyz1A4TEnBGy5bqqtSYkyjKTfKScdyZTBeFrTxJLiKGOIRWgwg==",
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.16.tgz",
+ "integrity": "sha512-LpAh8dQe4NKvhSW390Rkftw0ume0moSkRm575e1JZ1PwI/dXjbXyjpntq+2F0bVW1FV7V6B8EfWx088b+dNurw==",
"dev": true,
"dependencies": {
- "@types/node": "~20.11.3",
+ "@types/node": "~20.12.8",
"@types/ws": "~8.5.10"
}
},
@@ -144,10 +144,11 @@
"dev": true
},
"node_modules/prettier": {
- "version": "3.2.5",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
- "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz",
+ "integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==",
"dev": true,
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -235,15 +236,15 @@
}
},
"node_modules/tslib": {
- "version": "2.6.2",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
- "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
+ "version": "2.6.3",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
+ "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==",
"dev": true
},
"node_modules/typescript": {
- "version": "5.4.5",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
- "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
+ "version": "5.5.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz",
+ "integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==",
"peer": true,
"bin": {
"tsc": "bin/tsc",
diff --git a/test.ts b/test.ts
index c2eb65ee..b338205a 100644
--- a/test.ts
+++ b/test.ts
@@ -29,8 +29,10 @@ export const runContainer = async (
return containerID.trim();
};
-// executeScriptInContainer finds the only "coder_script"
-// resource in the given state and runs it in a container.
+/**
+ * Finds the only "coder_script" resource in the given state and runs it in a
+ * container.
+ */
export const executeScriptInContainer = async (
state: TerraformState,
image: string,
@@ -76,27 +78,30 @@ export const execContainer = async (
};
};
+type JsonValue =
+ | string
+ | number
+ | boolean
+ | null
+ | JsonValue[]
+ | { [key: string]: JsonValue };
+
+type TerraformStateResource = {
+ type: string;
+ name: string;
+ provider: string;
+ instances: [{ attributes: Record }];
+};
+
export interface TerraformState {
outputs: {
[key: string]: {
type: string;
value: any;
};
- }
- resources: [
- {
- type: string;
- name: string;
- provider: string;
- instances: [
- {
- attributes: {
- [key: string]: any;
- };
- },
- ];
- },
- ];
+ };
+
+ resources: [TerraformStateResource, ...TerraformStateResource[]];
}
export interface CoderScriptAttributes {
@@ -105,10 +110,11 @@ export interface CoderScriptAttributes {
url: string;
}
-// findResourceInstance finds the first instance of the given resource
-// type in the given state. If name is specified, it will only find
-// the instance with the given name.
-export const findResourceInstance = (
+/**
+ * finds the first instance of the given resource type in the given state. If
+ * name is specified, it will only find the instance with the given name.
+ */
+export const findResourceInstance = (
state: TerraformState,
type: T,
name?: string,
@@ -131,12 +137,13 @@ export const findResourceInstance = (
return resource.instances[0].attributes as any;
};
-// testRequiredVariables creates a test-case
-// for each variable provided and ensures that
-// the apply fails without it.
-export const testRequiredVariables = (
+/**
+ * Creates a test-case for each variable provided and ensures that the apply
+ * fails without it.
+ */
+export const testRequiredVariables = >(
dir: string,
- vars: Record,
+ vars: TVars,
) => {
// Ensures that all required variables are provided.
it("required variables", async () => {
@@ -165,16 +172,25 @@ export const testRequiredVariables = (
});
};
-// runTerraformApply runs terraform apply in the given directory
-// with the given variables. It is fine to run in parallel with
-// other instances of this function, as it uses a random state file.
-export const runTerraformApply = async (
+/**
+ * Runs terraform apply in the given directory with the given variables. It is
+ * fine to run in parallel with other instances of this function, as it uses a
+ * random state file.
+ */
+export const runTerraformApply = async <
+ TVars extends Readonly>,
+>(
dir: string,
- vars: Record,
- env: Record = {},
+ vars: TVars,
+ env?: Record,
): Promise => {
const stateFile = `${dir}/${crypto.randomUUID()}.tfstate`;
- Object.keys(vars).forEach((key) => (env[`TF_VAR_${key}`] = vars[key]));
+
+ const combinedEnv = env === undefined ? {} : { ...env };
+ for (const [key, value] of Object.entries(vars)) {
+ combinedEnv[`TF_VAR_${key}`] = String(value);
+ }
+
const proc = spawn(
[
"terraform",
@@ -188,22 +204,26 @@ export const runTerraformApply = async (
],
{
cwd: dir,
- env,
+ env: combinedEnv,
stderr: "pipe",
stdout: "pipe",
},
);
+
const text = await readableStreamToText(proc.stderr);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(text);
}
+
const content = await readFile(stateFile, "utf8");
await unlink(stateFile);
return JSON.parse(content);
};
-// runTerraformInit runs terraform init in the given directory.
+/**
+ * Runs terraform init in the given directory.
+ */
export const runTerraformInit = async (dir: string) => {
const proc = spawn(["terraform", "init"], {
cwd: dir,
@@ -221,8 +241,8 @@ export const createJSONResponse = (obj: object, statusCode = 200): Response => {
"Content-Type": "application/json",
},
status: statusCode,
- })
-}
+ });
+};
export const writeCoder = async (id: string, script: string) => {
const exec = await execContainer(id, [
diff --git a/vscode-desktop/main.test.ts b/vscode-desktop/main.test.ts
index e61e6ecb..74c4ffbd 100644
--- a/vscode-desktop/main.test.ts
+++ b/vscode-desktop/main.test.ts
@@ -43,7 +43,7 @@ describe("vscode-desktop", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
folder: "/foo/bar",
- open_recent: true,
+ open_recent: "true",
});
expect(state.outputs.vscode_url.value).toBe(
"vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
@@ -54,7 +54,7 @@ describe("vscode-desktop", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
folder: "/foo/bar",
- openRecent: false,
+ openRecent: "false",
});
expect(state.outputs.vscode_url.value).toBe(
"vscode://coder.coder-remote/open?owner=default&workspace=default&folder=/foo/bar&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
@@ -64,7 +64,7 @@ describe("vscode-desktop", async () => {
it("adds open_recent", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
- open_recent: true,
+ open_recent: "true",
});
expect(state.outputs.vscode_url.value).toBe(
"vscode://coder.coder-remote/open?owner=default&workspace=default&openRecent&url=https://mydeployment.coder.com&token=$SESSION_TOKEN",
diff --git a/windows-rdp/README.md b/windows-rdp/README.md
new file mode 100644
index 00000000..d80fc42f
--- /dev/null
+++ b/windows-rdp/README.md
@@ -0,0 +1,57 @@
+---
+display_name: Windows RDP
+description: RDP Server and Web Client, powered by Devolutions Gateway
+icon: ../.icons/desktop.svg
+maintainer_github: coder
+verified: true
+tags: [windows, rdp, web, desktop]
+---
+
+# Windows RDP
+
+Enable Remote Desktop + a web based client on Windows workspaces, powered by [devolutions-gateway](https://github.com/Devolutions/devolutions-gateway).
+
+```tf
+# AWS example. See below for examples of using this module with other providers
+module "windows_rdp" {
+ source = "registry.coder.com/coder/module/windows-rdp"
+ version = "1.0.16"
+ count = data.coder_workspace.me.start_count
+ agent_id = resource.coder_agent.main.id
+ resource_id = resource.aws_instance.dev.id
+}
+```
+
+## Video
+
+https://github.com/coder/modules/assets/28937484/fb5f4a55-7b69-4550-ab62-301e13a4be02
+
+## Examples
+
+### With AWS
+
+```tf
+module "windows_rdp" {
+ source = "registry.coder.com/coder/module/windows-rdp"
+ version = "1.0.16"
+ count = data.coder_workspace.me.start_count
+ agent_id = resource.coder_agent.main.id
+ resource_id = resource.aws_instance.dev.id
+}
+```
+
+### With Google Cloud
+
+```tf
+module "windows_rdp" {
+ source = "registry.coder.com/coder/module/windows-rdp"
+ version = "1.0.16"
+ count = data.coder_workspace.me.start_count
+ agent_id = resource.coder_agent.main.id
+ resource_id = resource.google_compute_instance.dev[0].id
+}
+```
+
+## Roadmap
+
+- [ ] Test on Microsoft Azure.
diff --git a/windows-rdp/devolutions-patch.js b/windows-rdp/devolutions-patch.js
new file mode 100644
index 00000000..020a40f1
--- /dev/null
+++ b/windows-rdp/devolutions-patch.js
@@ -0,0 +1,409 @@
+// @ts-check
+/**
+ * @file Defines the custom logic for patching in UI changes/behavior into the
+ * base Devolutions Gateway Angular app.
+ *
+ * Defined as a JS file to remove the need to have a separate compilation step.
+ * It is highly recommended that you work on this file from within VS Code so
+ * that you can take advantage of the @ts-check directive and get some type-
+ * checking still.
+ *
+ * Other notes about the weird ways this file is set up:
+ * - A lot of the HTML selectors in this file will look nonstandard. This is
+ * because they are actually custom Angular components.
+ * - It is strongly advised that you avoid template literals that use the
+ * placeholder syntax via the dollar sign. The Terraform file is treating this
+ * as a template file, and because it also uses a similar syntax, there's a
+ * risk that some values will trigger false positives. If a template literal
+ * must be used, be sure to use a double dollar sign to escape things.
+ * - All the CSS should be written via custom style tags and the !important
+ * directive (as much as that is a bad idea most of the time). We do not
+ * control the Angular app, so we have to modify things from afar to ensure
+ * that as Angular's internal state changes, it doesn't modify its HTML nodes
+ * in a way that causes our custom styles to get wiped away.
+ *
+ * @typedef {Readonly<{ querySelector: string; value: string; }>} FormFieldEntry
+ * @typedef {Readonly>} FormFieldEntries
+ */
+
+/**
+ * The communication protocol to set Devolutions to.
+ */
+const PROTOCOL = "RDP";
+
+/**
+ * The hostname to use with Devolutions.
+ */
+const HOSTNAME = "localhost";
+
+/**
+ * How often to poll the screen for the main Devolutions form.
+ */
+const SCREEN_POLL_INTERVAL_MS = 500;
+
+/**
+ * The fields in the Devolutions sign-in form that should be populated with
+ * values from the Coder workspace.
+ *
+ * All properties should be defined as placeholder templates in the form
+ * VALUE_NAME. The Coder module, when spun up, should then run some logic to
+ * replace the template slots with actual values. These values should never
+ * change from within JavaScript itself.
+ *
+ * @satisfies {FormFieldEntries}
+ */
+const formFieldEntries = {
+ /** @readonly */
+ username: {
+ /** @readonly */
+ querySelector: "web-client-username-control input",
+
+ /** @readonly */
+ value: "${CODER_USERNAME}",
+ },
+
+ /** @readonly */
+ password: {
+ /** @readonly */
+ querySelector: "web-client-password-control input",
+
+ /** @readonly */
+ value: "${CODER_PASSWORD}",
+ },
+};
+
+/**
+ * Handles typing in the values for the input form. All values are written
+ * immediately, even though that would be physically impossible with a real
+ * keyboard.
+ *
+ * Note: this code will never break, but you might get warnings in the console
+ * from Angular about unexpected value changes. Angular patches over a lot of
+ * the built-in browser APIs to support its component change detection system.
+ * As part of that, it has validations for checking whether an input it
+ * previously had control over changed without it doing anything.
+ *
+ * But the only way to simulate a keyboard input is by setting the input's
+ * .value property, and then firing an input event. So basically, the inner
+ * value will change, which Angular won't be happy about, but then the input
+ * event will fire and sync everything back together.
+ *
+ * @param {HTMLInputElement} inputField
+ * @param {string} inputText
+ * @returns {Promise}
+ */
+function setInputValue(inputField, inputText) {
+ return new Promise((resolve, reject) => {
+ // Adding timeout for input event, even though we'll be dispatching it
+ // immediately, just in the off chance that something in the Angular app
+ // intercepts it or stops it from propagating properly
+ const timeoutId = window.setTimeout(() => {
+ reject(new Error("Input event did not get processed correctly in time."));
+ }, 3_000);
+
+ const handleSuccessfulDispatch = () => {
+ window.clearTimeout(timeoutId);
+ inputField.removeEventListener("input", handleSuccessfulDispatch);
+ resolve();
+ };
+
+ inputField.addEventListener("input", handleSuccessfulDispatch);
+
+ // Code assumes that Angular will have an event handler in place to handle
+ // the new event
+ const inputEvent = new Event("input", {
+ bubbles: true,
+ cancelable: true,
+ });
+
+ inputField.value = inputText;
+ inputField.dispatchEvent(inputEvent);
+ });
+}
+
+/**
+ * Takes a Devolutions remote session form, auto-fills it with data, and then
+ * submits it.
+ *
+ * The logic here is more convoluted than it should be for two main reasons:
+ * 1. Devolutions' HTML markup has errors. There are labels, but they aren't
+ * bound to the inputs they're supposed to describe. This means no easy hooks
+ * for selecting the elements, unfortunately.
+ * 2. Trying to modify the .value properties on some of the inputs doesn't
+ * work. Probably some combo of Angular data-binding and some inputs having
+ * the readonly attribute. Have to simulate user input to get around this.
+ *
+ * @param {HTMLFormElement} myForm
+ * @returns {Promise}
+ */
+async function autoSubmitForm(myForm) {
+ const setProtocolValue = () => {
+ /** @type {HTMLDivElement | null} */
+ const protocolDropdownTrigger = myForm.querySelector('div[role="button"]');
+ if (protocolDropdownTrigger === null) {
+ throw new Error("No clickable trigger for setting protocol value");
+ }
+
+ protocolDropdownTrigger.click();
+
+ // Can't use form as container for querying the list of dropdown options,
+ // because the elements don't actually exist inside the form. They're placed
+ // in the top level of the HTML doc, and repositioned to make it look like
+ // they're part of the form. Avoids CSS stacking context issues, maybe?
+ /** @type {HTMLLIElement | null} */
+ const protocolOption = document.querySelector(
+ 'p-dropdownitem[ng-reflect-label="' + PROTOCOL + '"] li',
+ );
+
+ if (protocolOption === null) {
+ throw new Error(
+ "Unable to find protocol option on screen that matches desired protocol",
+ );
+ }
+
+ protocolOption.click();
+ };
+
+ const setHostname = () => {
+ /** @type {HTMLInputElement | null} */
+ const hostnameInput = myForm.querySelector("p-autocomplete#hostname input");
+
+ if (hostnameInput === null) {
+ throw new Error("Unable to find field for adding hostname");
+ }
+
+ return setInputValue(hostnameInput, HOSTNAME);
+ };
+
+ const setCoderFormFieldValues = async () => {
+ // The RDP form will not appear on screen unless the dropdown is set to use
+ // the RDP protocol
+ const rdpSubsection = myForm.querySelector("rdp-form");
+ if (rdpSubsection === null) {
+ throw new Error(
+ "Unable to find RDP subsection. Is the value of the protocol set to RDP?",
+ );
+ }
+
+ for (const { value, querySelector } of Object.values(formFieldEntries)) {
+ /** @type {HTMLInputElement | null} */
+ const input = document.querySelector(querySelector);
+
+ if (input === null) {
+ throw new Error(
+ 'Unable to element that matches query "' + querySelector + '"',
+ );
+ }
+
+ await setInputValue(input, value);
+ }
+ };
+
+ const triggerSubmission = () => {
+ /** @type {HTMLButtonElement | null} */
+ const submitButton = myForm.querySelector(
+ 'p-button[ng-reflect-type="submit"] button',
+ );
+
+ if (submitButton === null) {
+ throw new Error("Unable to find submission button");
+ }
+
+ if (submitButton.disabled) {
+ throw new Error(
+ "Unable to submit form because submit button is disabled. Are all fields filled out correctly?",
+ );
+ }
+
+ submitButton.click();
+ };
+
+ setProtocolValue();
+ await setHostname();
+ await setCoderFormFieldValues();
+ triggerSubmission();
+}
+
+/**
+ * Sets up logic for auto-populating the form data when the form appears on
+ * screen.
+ *
+ * @returns {void}
+ */
+function setupFormDetection() {
+ /** @type {HTMLFormElement | null} */
+ let formValueFromLastMutation = null;
+
+ /** @returns {void} */
+ const onDynamicTabMutation = () => {
+ /** @type {HTMLFormElement | null} */
+ const latestForm = document.querySelector("web-client-form > form");
+
+ // Only try to auto-fill if we went from having no form on screen to
+ // having a form on screen. That way, we don't accidentally override the
+ // form if the user is trying to customize values, and this essentially
+ // makes the script values function as default values
+ const mounted = formValueFromLastMutation === null && latestForm !== null;
+ if (mounted) {
+ autoSubmitForm(latestForm);
+ }
+
+ formValueFromLastMutation = latestForm;
+ };
+
+ /** @type {number | undefined} */
+ let pollingId = undefined;
+
+ /** @returns {void} */
+ const checkScreenForDynamicTab = () => {
+ const dynamicTab = document.querySelector("web-client-dynamic-tab");
+
+ // Keep polling until the main content container is on screen
+ if (dynamicTab === null) {
+ return;
+ }
+
+ window.clearInterval(pollingId);
+
+ // Call the mutation callback manually, to ensure it runs at least once
+ onDynamicTabMutation();
+
+ // Having the mutation observer is kind of an extra safety net that isn't
+ // really expected to run that often. Most of the content in the dynamic
+ // tab is being rendered through Canvas, which won't trigger any mutations
+ // that the observer can detect
+ const dynamicTabObserver = new MutationObserver(onDynamicTabMutation);
+ dynamicTabObserver.observe(dynamicTab, {
+ subtree: true,
+ childList: true,
+ });
+ };
+
+ pollingId = window.setInterval(
+ checkScreenForDynamicTab,
+ SCREEN_POLL_INTERVAL_MS,
+ );
+}
+
+/**
+ * Sets up custom styles for hiding default Devolutions elements that Coder
+ * users shouldn't need to care about.
+ *
+ * @returns {void}
+ */
+function setupAlwaysOnStyles() {
+ const styleId = "coder-patch--styles-always-on";
+ const existingContainer = document.querySelector("#" + styleId);
+ if (existingContainer) {
+ return;
+ }
+
+ const styleContainer = document.createElement("style");
+ styleContainer.id = styleId;
+ styleContainer.innerHTML = `
+ /* app-menu corresponds to the sidebar of the default view. */
+ app-menu {
+ display: none !important;
+ }
+ `;
+
+ document.head.appendChild(styleContainer);
+}
+
+function hideFormForInitialSubmission() {
+ const styleId = "coder-patch--styles-initial-submission";
+ const cssOpacityVariableName = "--coder-opacity-multiplier";
+
+ /** @type {HTMLStyleElement | null} */
+ let styleContainer = document.querySelector("#" + styleId);
+ if (!styleContainer) {
+ styleContainer = document.createElement("style");
+ styleContainer.id = styleId;
+ styleContainer.innerHTML = `
+ /*
+ Have to use opacity instead of visibility, because the element still
+ needs to be interactive via the script so that it can be auto-filled.
+ */
+ :root {
+ /*
+ Can be 0 or 1. Start off invisible to avoid risks of UI flickering,
+ but the rest of the function should be in charge of making the form
+ container visible again if something goes wrong during setup.
+
+ Double dollar sign needed to avoid Terraform script false positives
+ */
+ $${cssOpacityVariableName}: 0;
+ }
+
+ /*
+ web-client-form is the container for the main session form, while
+ the div is for the dropdown that is used for selecting the protocol.
+ The dropdown is not inside of the form for CSS styling reasons, so we
+ need to select both.
+ */
+ web-client-form,
+ body > div.p-overlay {
+ /*
+ Double dollar sign needed to avoid Terraform script false positives
+ */
+ opacity: calc(100% * var($${cssOpacityVariableName})) !important;
+ }
+ `;
+
+ document.head.appendChild(styleContainer);
+ }
+
+ // The root node being undefined should be physically impossible (if it's
+ // undefined, the browser itself is busted), but we need to do a type check
+ // here so that the rest of the function doesn't need to do type checks over
+ // and over.
+ const rootNode = document.querySelector(":root");
+ if (!(rootNode instanceof HTMLHtmlElement)) {
+ // Remove the container entirely because if the browser is busted, who knows
+ // if the CSS variables can be applied correctly. Better to have something
+ // be a bit more ugly/painful to use, than have it be impossible to use
+ styleContainer.remove();
+ return;
+ }
+
+ // It's safe to make the form visible preemptively because Devolutions
+ // outputs the Windows view through an HTML canvas that it overlays on top
+ // of the rest of the app. Even if the form isn't hidden at the style level,
+ // it will still be covered up.
+ const restoreOpacity = () => {
+ rootNode.style.setProperty(cssOpacityVariableName, "1");
+ };
+
+ // If this file gets more complicated, it might make sense to set up the
+ // timeout and event listener so that if one triggers, it cancels the other,
+ // but having restoreOpacity run more than once is a no-op for right now.
+ // Not a big deal if these don't get cleaned up.
+
+ // Have the form automatically reappear no matter what, so that if something
+ // does break, the user isn't left out to dry
+ window.setTimeout(restoreOpacity, 5_000);
+
+ /** @type {HTMLFormElement | null} */
+ const form = document.querySelector("web-client-form > form");
+ form?.addEventListener(
+ "submit",
+ () => {
+ // Not restoring opacity right away just to give the HTML canvas a little
+ // bit of time to get spun up and cover up the main form
+ window.setTimeout(restoreOpacity, 1_000);
+ },
+ { once: true },
+ );
+}
+
+// Always safe to call these immediately because even if the Angular app isn't
+// loaded by the time the function gets called, the CSS will always be globally
+// available for when Angular is finally ready
+setupAlwaysOnStyles();
+hideFormForInitialSubmission();
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", setupFormDetection);
+} else {
+ setupFormDetection();
+}
diff --git a/windows-rdp/main.test.ts b/windows-rdp/main.test.ts
new file mode 100644
index 00000000..24ce1049
--- /dev/null
+++ b/windows-rdp/main.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from "bun:test";
+import {
+ TerraformState,
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "../test";
+
+type TestVariables = Readonly<{
+ agent_id: string;
+ resource_id: string;
+ admin_username?: string;
+ admin_password?: string;
+}>;
+
+function findWindowsRdpScript(state: TerraformState): string | null {
+ for (const resource of state.resources) {
+ const isRdpScriptResource =
+ resource.type === "coder_script" && resource.name === "windows-rdp";
+
+ if (!isRdpScriptResource) {
+ continue;
+ }
+
+ for (const instance of resource.instances) {
+ if (instance.attributes.display_name === "windows-rdp") {
+ return instance.attributes.script;
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * @todo It would be nice if we had a way to verify that the Devolutions root
+ * HTML file is modified to include the import for the patched Coder script,
+ * but the current test setup doesn't really make that viable
+ */
+describe("Web RDP", async () => {
+ await runTerraformInit(import.meta.dir);
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "foo",
+ resource_id: "bar",
+ });
+
+ it("Has the PowerShell script install Devolutions Gateway", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ resource_id: "bar",
+ });
+
+ const lines = findWindowsRdpScript(state)
+ ?.split("\n")
+ .filter(Boolean)
+ .map((line) => line.trim());
+
+ expect(lines).toEqual(
+ expect.arrayContaining([
+ '$moduleName = "DevolutionsGateway"',
+ // Devolutions does versioning in the format year.minor.patch
+ expect.stringMatching(/^\$moduleVersion = "\d{4}\.\d+\.\d+"$/),
+ "Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force",
+ ]),
+ );
+ });
+
+ it("Injects Terraform's username and password into the JS patch file", async () => {
+ /**
+ * Using a regex as a quick-and-dirty way to get at the username and
+ * password values.
+ *
+ * Tried going through the trouble of extracting out the form entries
+ * variable from the main output, converting it from Prettier/JS-based JSON
+ * text to universal JSON text, and exposing it as a parsed JSON value. That
+ * got to be a bit too much, though.
+ *
+ * Regex is a little bit more verbose and pedantic than normal. Want to
+ * have some basic safety nets for validating the structure of the form
+ * entries variable after the JS file has had values injected. Even with all
+ * the wildcard classes set to lazy mode, we want to make sure that they
+ * don't overshoot and grab too much content.
+ *
+ * Written and tested via Regex101
+ * @see {@link https://regex101.com/r/UMgQpv/2}
+ */
+ const formEntryValuesRe =
+ /^const formFieldEntries = \{$.*?^\s+username: \{$.*?^\s*?querySelector.*?,$.*?^\s*value: "(?.+?)",$.*?password: \{$.*?^\s+querySelector: .*?,$.*?^\s*value: "(?.+?)",$.*?^};$/ms;
+
+ // Test that things work with the default username/password
+ const defaultState = await runTerraformApply(
+ import.meta.dir,
+ {
+ agent_id: "foo",
+ resource_id: "bar",
+ },
+ );
+
+ const defaultRdpScript = findWindowsRdpScript(defaultState);
+ expect(defaultRdpScript).toBeString();
+
+ const { username: defaultUsername, password: defaultPassword } =
+ formEntryValuesRe.exec(defaultRdpScript)?.groups ?? {};
+
+ expect(defaultUsername).toBe("Administrator");
+ expect(defaultPassword).toBe("coderRDP!");
+
+ // Test that custom usernames/passwords are also forwarded correctly
+ const customAdminUsername = "crouton";
+ const customAdminPassword = "VeryVeryVeryVeryVerySecurePassword97!";
+ const customizedState = await runTerraformApply(
+ import.meta.dir,
+ {
+ agent_id: "foo",
+ resource_id: "bar",
+ admin_username: customAdminUsername,
+ admin_password: customAdminPassword,
+ },
+ );
+
+ const customRdpScript = findWindowsRdpScript(customizedState);
+ expect(customRdpScript).toBeString();
+
+ const { username: customUsername, password: customPassword } =
+ formEntryValuesRe.exec(customRdpScript)?.groups ?? {};
+
+ expect(customUsername).toBe(customAdminUsername);
+ expect(customPassword).toBe(customAdminPassword);
+ });
+});
diff --git a/windows-rdp/main.tf b/windows-rdp/main.tf
new file mode 100644
index 00000000..8d874fa3
--- /dev/null
+++ b/windows-rdp/main.tf
@@ -0,0 +1,76 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 0.17"
+ }
+ }
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "resource_id" {
+ type = string
+ description = "The ID of the primary Coder resource (e.g. VM)."
+}
+
+variable "admin_username" {
+ type = string
+ default = "Administrator"
+}
+
+variable "admin_password" {
+ type = string
+ default = "coderRDP!"
+ sensitive = true
+}
+
+resource "coder_script" "windows-rdp" {
+ agent_id = var.agent_id
+ display_name = "windows-rdp"
+ icon = "/icon/desktop.svg"
+
+ script = templatefile("${path.module}/powershell-installation-script.tftpl", {
+ admin_username = var.admin_username
+ admin_password = var.admin_password
+
+ # Wanted to have this be in the powershell template file, but Terraform
+ # doesn't allow recursive calls to the templatefile function. Have to feed
+ # results of the JS template replace into the powershell template
+ patch_file_contents = templatefile("${path.module}/devolutions-patch.js", {
+ CODER_USERNAME = var.admin_username
+ CODER_PASSWORD = var.admin_password
+ })
+ })
+
+ run_on_start = true
+}
+
+resource "coder_app" "windows-rdp" {
+ agent_id = var.agent_id
+ slug = "web-rdp"
+ display_name = "Web RDP"
+ url = "http://localhost:7171"
+ icon = "https://svgur.com/i/158F.svg"
+ subdomain = true
+
+ healthcheck {
+ url = "http://localhost:7171"
+ interval = 5
+ threshold = 15
+ }
+}
+
+resource "coder_app" "rdp-docs" {
+ agent_id = var.agent_id
+ display_name = "Local RDP"
+ slug = "rdp-docs"
+ icon = "https://raw.githubusercontent.com/matifali/logos/main/windows.svg"
+ url = "https://coder.com/docs/ides/remote-desktops#rdp-desktop"
+ external = true
+}
diff --git a/windows-rdp/powershell-installation-script.tftpl b/windows-rdp/powershell-installation-script.tftpl
new file mode 100644
index 00000000..1b7ab487
--- /dev/null
+++ b/windows-rdp/powershell-installation-script.tftpl
@@ -0,0 +1,85 @@
+function Set-AdminPassword {
+ param (
+ [string]$adminPassword
+ )
+ # Set admin password
+ Get-LocalUser -Name "${admin_username}" | Set-LocalUser -Password (ConvertTo-SecureString -AsPlainText $adminPassword -Force)
+ # Enable admin user
+ Get-LocalUser -Name "${admin_username}" | Enable-LocalUser
+}
+
+function Configure-RDP {
+ # Enable RDP
+ New-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name "fDenyTSConnections" -Value 0 -PropertyType DWORD -Force
+ # Disable NLA
+ New-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "UserAuthentication" -Value 0 -PropertyType DWORD -Force
+ New-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name "SecurityLayer" -Value 1 -PropertyType DWORD -Force
+ # Enable RDP through Windows Firewall
+ Enable-NetFirewallRule -DisplayGroup "Remote Desktop"
+}
+
+function Install-DevolutionsGateway {
+# Define the module name and version
+$moduleName = "DevolutionsGateway"
+$moduleVersion = "2024.1.5"
+
+# Install the module with the specified version for all users
+# This requires administrator privileges
+try {
+ # Install-PackageProvider is required for AWS. Need to set command to
+ # terminate on failure so that try/catch actually triggers
+ Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -ErrorAction Stop
+ Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force
+}
+catch {
+ # If the first command failed, assume that we're on GCP and run
+ # Install-Module only
+ Install-Module -Name $moduleName -RequiredVersion $moduleVersion -Force
+}
+
+# Construct the module path for system-wide installation
+$moduleBasePath = "C:\Windows\system32\config\systemprofile\Documents\PowerShell\Modules\$moduleName\$moduleVersion"
+$modulePath = Join-Path -Path $moduleBasePath -ChildPath "$moduleName.psd1"
+
+# Import the module using the full path
+Import-Module $modulePath
+Install-DGatewayPackage
+
+# Configure Devolutions Gateway
+$Hostname = "localhost"
+$HttpListener = New-DGatewayListener 'http://*:7171' 'http://*:7171'
+$WebApp = New-DGatewayWebAppConfig -Enabled $true -Authentication None
+$ConfigParams = @{
+ Hostname = $Hostname
+ Listeners = @($HttpListener)
+ WebApp = $WebApp
+}
+Set-DGatewayConfig @ConfigParams
+New-DGatewayProvisionerKeyPair -Force
+
+# Configure and start the Windows service
+Set-Service 'DevolutionsGateway' -StartupType 'Automatic'
+Start-Service 'DevolutionsGateway'
+}
+
+function Patch-Devolutions-HTML {
+$root = "C:\Program Files\Devolutions\Gateway\webapp\client"
+$devolutionsHtml = "$root\index.html"
+$patch = ''
+
+# Always copy the file in case we change it.
+@'
+${patch_file_contents}
+'@ | Set-Content "$root\coder.js"
+
+# Only inject the src if we have not before.
+$isPatched = Select-String -Path "$devolutionsHtml" -Pattern "$patch" -SimpleMatch
+if ($isPatched -eq $null) {
+ (Get-Content $devolutionsHtml).Replace('', "$patch") | Set-Content $devolutionsHtml
+}
+}
+
+Set-AdminPassword -adminPassword "${admin_password}"
+Configure-RDP
+Install-DevolutionsGateway
+Patch-Devolutions-HTML