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