diff --git a/e2e-tests/coolify_deploy.spec.ts b/e2e-tests/coolify_deploy.spec.ts index 0a112662d3..f249c9c7b5 100644 --- a/e2e-tests/coolify_deploy.spec.ts +++ b/e2e-tests/coolify_deploy.spec.ts @@ -78,6 +78,8 @@ async function connectGithubFromPublishPanel(po: any) { */ async function connectCoolify(po: any, fakeLlmPort: number) { await po.page.getByRole("tab", { name: "Your Own Server" }).click(); + // The tab opens on the installer, so switch to the paste-a-token form. + await po.page.getByTestId("coolify-setup-use-existing").click(); await po.page .getByTestId("coolify-instance-url") diff --git a/e2e-tests/coolify_setup.spec.ts b/e2e-tests/coolify_setup.spec.ts new file mode 100644 index 0000000000..23821b883b --- /dev/null +++ b/e2e-tests/coolify_setup.spec.ts @@ -0,0 +1,168 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { expect } from "@playwright/test"; +import { testWithConfig, type ElectronConfig } from "./helpers/test_helper"; +import { Timeout } from "./helpers/constants"; +import { + startFakeSshServer, + type FakeSshServer, +} from "../src/testing/fake_ssh_server"; + +/** + * Installing Coolify onto a server, through the packaged app. + * + * Two tests, both of which need real Chromium and the shipped build: the whole + * chain through to a stored token, and a server Dyad refuses. Everything that + * does not look at the screen belongs in + * src/coolify_setup/setup_flow.integration.test.ts, which drives the same SSH + * server in a fraction of the time. + * + * Not covered anywhere: whether Coolify's installer seeds an account, whether + * its seeder accepts an address, whether a certificate is issued. Those need a + * real machine. + */ + +/** + * One server per test, started before the app is. + * + * The app launches during fixture setup, so anything the app must see in its + * environment has to exist by then — which is why this runs in the pre-launch + * hook rather than in the test body. What each test wants the server to do is + * set afterwards by adjusting `state`, which the fake reads on every command. + */ +let sshServer: FakeSshServer | null = null; + +const electronConfig: ElectronConfig = { + preLaunchHook: async ({ userDataDir, fakeLlmPort }) => { + await fs.mkdir(userDataDir, { recursive: true }); + // The feature is behind an experiment, off unless the user turns it on. + await fs.writeFile( + path.join(userDataDir, "user-settings.json"), + JSON.stringify({ enableOwnServerDeployment: true }), + "utf8", + ); + + sshServer = await startFakeSshServer(); + // The form asks for an address, not a port, so both of these travel + // through the same e2e seam as every other test-only behaviour here. + process.env.DYAD_E2E_SSH_PORT = String(sshServer.port); + process.env.DYAD_E2E_DASHBOARD_PORT = String(fakeLlmPort); + // Whatever another spec left in the shared fake is not this test's setup. + await resetCoolify(fakeLlmPort); + }, +}; + +const test = testWithConfig(electronConfig); + +/** The server this test is talking to, adjustable before anything is done. */ +function server(): FakeSshServer { + if (!sshServer) throw new Error("The fake server was never started."); + return sshServer; +} + +test.afterEach(async () => { + delete process.env.DYAD_E2E_SSH_PORT; + delete process.env.DYAD_E2E_DASHBOARD_PORT; + await sshServer?.close(); + sshServer = null; +}); + +/** The fake Coolify is shared per worker, so each test says what it expects. */ +async function resetCoolify(port: number) { + await fetch(`http://localhost:${port}/coolify/test/reset`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); +} + +async function openInstaller(po: any) { + // The publish panel needs an app to be about, and the deployment section it + // carries needs a repository — Coolify deploys from one, so the tab that + // holds the installer only appears once GitHub is connected. + await po.sendPrompt("hi"); + await po.previewPanel.selectPreviewMode("publish"); + await po.githubConnector.connect(); + await po.githubConnector.createRepo(`coolify-setup-e2e-${Date.now()}`); + await po.page.getByRole("tab", { name: "Your Own Server" }).click(); + await expect(po.page.getByTestId("coolify-server-setup")).toBeVisible(); +} + +async function fillAndInstall(po: any) { + await po.page.getByTestId("coolify-setup-host").fill("127.0.0.1"); + await po.page.getByTestId("coolify-setup-email").fill("me@gmail.com"); + // Install is offered only for a server Dyad has looked at, so this is the + // ordinary path rather than an extra step for the test. + await po.page.getByTestId("coolify-setup-inspect").click(); + await expect(po.page.getByTestId("coolify-setup-inspection")).toBeVisible({ + timeout: Timeout.MEDIUM, + }); + await po.page.getByTestId("coolify-setup-install").click(); +} + +test("installs Coolify onto a server and connects to it", async ({ po }) => { + await po.setUp({ autoApprove: true }); + await openInstaller(po); + + // The key is the one manual step: nothing can reach the server without it. + await expect(po.page.getByTestId("coolify-setup-public-key")).toContainText( + "ssh-ed25519", + ); + + await fillAndInstall(po); + + // A loopback address can never be given a certificate, so the install ends + // on plain HTTP — and the screen stays up to ask, because the token it + // would keep travels over that address on every deploy. + await expect(po.page.getByTestId("coolify-setup-done")).toBeVisible({ + timeout: Timeout.LONG, + }); + await expect(po.page.getByTestId("coolify-setup-insecure")).toBeVisible(); + await expect(po.page.getByTestId("coolify-setup-done")).toContainText( + "It is not kept unless you say so", + ); + + // Saying so is what stores it. Ticking here is the whole of the difference + // between the picker below and the token form, which is what makes this the + // one place the agreement is proved end to end. + await po.page.getByTestId("coolify-setup-accept-insecure").click(); + await po.page.getByTestId("coolify-setup-continue").click(); + await expect(po.page.getByTestId("coolify-server-select")).toBeVisible({ + timeout: Timeout.MEDIUM, + }); + // The picker being present says only that a token was stored. This says the + // address stored with it is one Dyad can actually talk to: the servers came + // back from the instance the install pointed it at. + await po.page.getByTestId("coolify-server-select").click(); + // Named, not "the first option": while discovery is in flight the picker + // renders a "Loading servers..." row that is also an option, which would + // satisfy a looser assertion without a server ever arriving. + await expect(po.page.getByRole("option", { name: "production" })).toBeVisible( + { timeout: Timeout.MEDIUM }, + ); + + // Dyad offered a key rather than a password, and the installer really ran. + expect(server().state.keyOffered).toBe(true); + expect(server().state.commands.some((c) => c.includes("install.sh"))).toBe( + true, + ); + expect(server().state.commands.some((c) => c.includes("tinker"))).toBe(true); +}); + +test("refuses a server that already has Coolify on it", async ({ po }) => { + server().state.probe = "mem=1967\ncontainer=coolify\nbusy=no\n"; + await po.setUp({ autoApprove: true }); + await openInstaller(po); + await po.page.getByTestId("coolify-setup-host").fill("127.0.0.1"); + // Filled, so that a disabled Install button after the check means the + // server was refused rather than that the form is incomplete. + await po.page.getByTestId("coolify-setup-email").fill("me@gmail.com"); + await po.page.getByTestId("coolify-setup-inspect").click(); + + await expect(po.page.getByTestId("coolify-setup-inspection")).toContainText( + "already has Coolify", + { timeout: Timeout.MEDIUM }, + ); + // And it really refuses: the button that would install over it is disabled. + await expect(po.page.getByTestId("coolify-setup-install")).toBeDisabled(); +}); diff --git a/forge.config.ts b/forge.config.ts index fde761d71f..b522d64a7d 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -44,8 +44,27 @@ const pgRuntimeDependencies = [ "xtend", ] as const; -function isPgRuntimeDependency(file: string): boolean { - return pgRuntimeDependencies.some((dependency) => { +/** + * What ssh2 needs at runtime, and only that. + * + * Its optional native helpers — cpu-features, nan, buildcheck — are left out + * on purpose. ssh2 guards those requires and falls back to pure JavaScript, + * so leaving them behind costs some speed and avoids shipping a binding + * compiled against whatever Node the build machine happened to have. + */ +const ssh2RuntimeDependencies = [ + "ssh2", + "asn1", + "safer-buffer", + "bcrypt-pbkdf", + "tweetnacl", +] as const; + +function isRuntimeDependency( + file: string, + packages: readonly string[], +): boolean { + return packages.some((dependency) => { const modulePath = `/node_modules/${dependency}`; return file === modulePath || file.startsWith(`${modulePath}/`); }); @@ -87,6 +106,9 @@ const ignore = (file: string) => { if (file.startsWith("/node_modules/node-pty")) { return false; } + if (isRuntimeDependency(file, ssh2RuntimeDependencies)) { + return false; + } if (file.startsWith("/node_modules/mustardscript")) { return false; } @@ -111,7 +133,7 @@ const ignore = (file: string) => { if (file.startsWith("/node_modules/@typescript/old")) { return false; } - if (isPgRuntimeDependency(file)) { + if (isRuntimeDependency(file, pgRuntimeDependencies)) { return false; } if (file === "/node_modules/ws" || file.startsWith("/node_modules/ws/")) { diff --git a/package-lock.json b/package-lock.json index f2974c80a2..39ed643c92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,6 +92,7 @@ "shell-env": "^4.0.1", "shiki": "^3.2.1", "sonner": "^2.0.3", + "ssh2": "^1.17.0", "stacktrace-js": "^2.0.2", "tailwind-merge": "^3.1.0", "tailwindcss": "^4.1.3", @@ -132,6 +133,7 @@ "@types/pg": "^8.20.0", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", + "@types/ssh2": "^1.15.5", "@types/use-sync-external-store": "^0.0.6", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^5.62.0", @@ -8121,6 +8123,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -9389,6 +9418,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -9572,6 +9610,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -9843,6 +9890,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -10636,6 +10692,20 @@ "node": ">= 0.10" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -18289,6 +18359,13 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -21804,6 +21881,23 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/ssri": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", @@ -22870,6 +22964,12 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index d893e4bcb5..408b5d38ce 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "shell-env": "^4.0.1", "shiki": "^3.2.1", "sonner": "^2.0.3", + "ssh2": "^1.17.0", "stacktrace-js": "^2.0.2", "tailwind-merge": "^3.1.0", "tailwindcss": "^4.1.3", @@ -190,6 +191,7 @@ "@types/pg": "^8.20.0", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", + "@types/ssh2": "^1.15.5", "@types/use-sync-external-store": "^0.0.6", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^5.62.0", diff --git a/src/components/CoolifyConnector.test.tsx b/src/components/CoolifyConnector.test.tsx index 7276bd6f52..fd2a3306f4 100644 --- a/src/components/CoolifyConnector.test.tsx +++ b/src/components/CoolifyConnector.test.tsx @@ -1,3 +1,6 @@ +import React, { useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/queryKeys"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -9,6 +12,63 @@ const toastMock = vi.hoisted(() => ({ })); vi.mock("sonner", () => ({ toast: toastMock })); +// Stubbed so these tests stay about the connector. The real one fetches +// secrets through its own mutation, which would drag a query client into every +// case here for something none of them are checking. +vi.mock("@/components/CoolifyCredentials", () => ({ + CoolifyCredentials: ({ showTitle }: { showTitle?: boolean }) => ( +
+ {showTitle ? "Your Coolify server" : null} +
+ ), +})); + +// Stubbed down to its two edges — that it is open, and the way to confirm — +// so these cases can check the wiring without the checkbox gating, which is +// the dialog's own test's job. +vi.mock("@/components/CoolifySignOutDialog", () => ({ + CoolifySignOutDialog: ({ + open, + onConfirm, + }: { + open: boolean; + onConfirm: () => void; + }) => + open ? ( + + ) : null, +})); + +// The real installer fetches a key and runs mutations of its own, none of +// which these cases are about. +vi.mock("@/components/CoolifyServerSetup", () => ({ + CoolifyServerSetup: ({ + children, + onUseExisting, + heldServerUrl, + }: { + children?: React.ReactNode; + onUseExisting?: (url?: string) => void; + heldServerUrl?: string | null; + }) => ( +
+ {/* Shown so the wiring is observable. The panel refuses to install + while this is set, and nothing else here would notice it being + dropped on the way in. */} + {heldServerUrl ?? ""} + + {children} +
+ ), +})); + /** * What the panel shows before it has an answer. * @@ -55,11 +115,76 @@ const loadedApp = vi.hoisted(() => ({ vi.mock("@/hooks/useLoadApp", () => ({ useLoadApp: () => ({ app: loadedApp.value, loading: false }), })); +const setup = vi.hoisted(() => ({ state: { type: "idle" } as unknown })); +const dismissMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("@/ipc/types", () => ({ - ipc: { system: { openExternalUrl: vi.fn() } }, + ipc: { + system: { openExternalUrl: vi.fn() }, + coolifySetup: { + snapshot: () => Promise.resolve(setup.state), + dismiss: dismissMock, + }, + events: { coolifySetup: { onChanged: () => () => {} } }, + }, })); -const { CoolifyConnector } = await import("./CoolifyConnector"); +const { CoolifyConnector: Panel } = await import("./CoolifyConnector"); + +// The installer's state is the main process's, shared by every window — so +// each case has to say what it is, or it inherits the last one's. +beforeEach(() => { + setup.state = { type: "idle" }; + dismissMock.mockClear(); +}); + +function CoolifyConnector(props: { appId: number | null }) { + // One client for the life of the test, so a rerender keeps the cache it + // built — a fresh one per render would throw away anything a test invalidated + // or cached and quietly pass on the re-seed below. + const [client] = useState( + () => new QueryClient({ defaultOptions: { queries: { retry: false } } }), + ); + // Seeded rather than fetched: what the main process is doing is present on + // the first render in the app too, once any window has asked. Waiting for + // it here would make an "it is not shown" assertion pass before the answer + // arrived, which is no assertion at all. + client.setQueryData(queryKeys.coolify.setup, setup.state); + return ( + + + + ); +} + +describe("an install that is going on", () => { + it("is shown even when this app's own status cannot be read", async () => { + // The install belongs to the machine, not to the app being looked at. Put + // behind that read, a multi-minute run is replaced by an error about + // something else, with its Cancel button out of reach. + deploy.value = { + status: undefined, + statusError: new Error("could not read the app"), + }; + setup.state = { + type: "running", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + step: "installing", + log: "", + stopping: false, + }; + render(); + + await waitFor(() => + expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(), + ); + expect(screen.queryByTestId("coolify-status-error")).toBeNull(); + }); +}); describe("before the status query has answered", () => { it("waits rather than claiming a failure when it is merely paused", () => { @@ -85,6 +210,630 @@ describe("before the status query has answered", () => { }); }); +/** + * Two things share this panel and they are not the same thing. + * + * The Coolify instance is the user's and outlives any app; where an app + * deploys is per app. Kept apart so the instance — and the way back into it — + * is readable without opening one app's settings. + */ +describe("the instance and the app are separate sections", () => { + function connected(connection: Record | null) { + deploy.value = { + status: { + hasToken: true, + instanceUrl: "https://coolify.test", + connection, + appUrl: null, + lastDeployedAt: null, + }, + discovery: { servers: [], projects: [] }, + }; + } + + it("shows the instance section while picking where an app deploys", async () => { + connected(null); + render(); + + expect(screen.getByTestId("coolify-instance-section")).toBeTruthy(); + expect(screen.getByTestId("coolify-credentials-stub")).toBeTruthy(); + expect(screen.getByText("Where this app deploys")).toBeTruthy(); + }); + + it("shows it once the app has somewhere to deploy too", async () => { + // Previously it appeared only while editing, so the instance details were + // reachable only by opening one app's settings. + connected({ + instanceUrl: "https://coolify.test", + serverUuid: "srv-1", + projectUuid: "prj-1", + environmentName: "production", + domain: null, + }); + render(); + + expect(screen.getByTestId("coolify-instance-section")).toBeTruthy(); + expect(screen.getByTestId("coolify-credentials-stub")).toBeTruthy(); + }); + + it("offers signing out from the instance section, not the app one", async () => { + connected(null); + render(); + + const section = screen.getByTestId("coolify-instance-section"); + expect(section.textContent).toContain("Sign out of Coolify"); + }); + + /** The default mock builds a fresh one per render, so nothing can watch it. */ + function watchClearToken() { + const mutateAsync = vi.fn(); + deploy.value.clearToken = { mutateAsync, isPending: false }; + return mutateAsync; + } + + it("asks before forgetting anything", async () => { + // Signing out throws away the password Dyad invented, and pressing the + // button is not the same as having read that. + connected(null); + const clearToken = watchClearToken(); + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Sign out of Coolify" }), + ); + + expect(clearToken).not.toHaveBeenCalled(); + expect(screen.getByTestId("confirm-sign-out")).toBeTruthy(); + }); + + it("forgets the instance once that is confirmed", async () => { + connected(null); + const clearToken = watchClearToken(); + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Sign out of Coolify" }), + ); + await user.click(screen.getByTestId("confirm-sign-out")); + + expect(clearToken).toHaveBeenCalled(); + expect(toastMock.success).toHaveBeenCalled(); + }); +}); + +/** With no token, installing is the landing screen; this is the other route. */ +async function openTokenForm(user: ReturnType) { + await user.click( + screen.getByRole("button", { name: "I already have Coolify installed" }), + ); +} + +const NO_TOKEN = { + status: { + hasToken: false, + tokenId: null, + instanceUrl: null, + serverUrl: null, + connection: null, + appUrl: null, + lastDeployedAt: null, + }, +}; + +/** Installed, and its API token could not be minted — so no token, but a + server whose account Dyad is the only holder of. */ +const SERVER_NO_TOKEN = { + status: { ...NO_TOKEN.status, serverUrl: "http://203.0.113.5:8000" }, +}; + +describe("a server Dyad set up but has no token for", () => { + it("will not set up another over the top of it", async () => { + // Installing again replaces the only copy of this one's password, so it + // is not something a screen offers on the way past. + deploy.value = SERVER_NO_TOKEN; + render(); + + expect(screen.getByTestId("coolify-already-has-server")).toBeTruthy(); + expect(screen.queryByTestId("coolify-server-setup-stub")).toBeNull(); + }); + + it("offers signing out as the way to a different Coolify", async () => { + // The only state where Dyad holds a Coolify and has no token to give up, + // so without this there is nothing here that reaches the account. + deploy.value = SERVER_NO_TOKEN; + render(); + + expect( + screen.getByRole("button", { name: "Sign out of Coolify" }), + ).toBeTruthy(); + }); + + it("pins the address to that server", async () => { + // A token typed against another address would leave the account naming + // one machine and the token another. + deploy.value = SERVER_NO_TOKEN; + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Enter an API token" }), + ); + const field = screen.getByTestId( + "coolify-instance-url", + ) as HTMLInputElement; + expect(field.readOnly).toBe(true); + await user.type(field, "https://somewhere-else.example.com"); + expect(field.value).toBe("http://203.0.113.5:8000"); + }); + + it("still refuses a new install after one was cancelled", async () => { + // Cancelling rests the machine in failed with nothing to report and no + // way to clear it, so treating that as a failure to make room for would + // hand the installer back for as long as the app is open. + deploy.value = SERVER_NO_TOKEN; + setup.state = { + type: "failed", + host: "203.0.113.5", + message: "Cancelled", + cancelled: true, + log: "", + }; + render(); + + expect(screen.getByTestId("coolify-already-has-server")).toBeTruthy(); + expect(screen.queryByTestId("coolify-server-setup-stub")).toBeNull(); + }); + + it("does not stand in front of an install that failed", async () => { + // The account is written partway through, so a failure after that point + // has one stored. The message, the log and the way to clear it are the + // installer's, and retrying is the ordinary thing to do next. + deploy.value = SERVER_NO_TOKEN; + setup.state = { + type: "failed", + host: "203.0.113.5", + message: "boom", + cancelled: false, + log: "", + }; + render(); + expect({ + failureVisible: Boolean( + screen.queryByTestId("coolify-server-setup-stub"), + ), + refusalCard: Boolean(screen.queryByTestId("coolify-already-has-server")), + // Installing again is refused while the account is stored, and the + // refusal says to sign out first — which has to be doable from here. + signOut: Boolean( + screen.queryByRole("button", { name: "Sign out of Coolify" }), + ), + }).toEqual({ failureVisible: true, refusalCard: false, signOut: true }); + }); + + it("says a cancelled install may have left something behind", async () => { + // Cancel reads as an undo, and it is not one: the installer may already + // have put Docker and Coolify on the server, which is why checking it + // again can answer that Coolify is already there. Saying nothing leaves + // that refusal looking like it came from nowhere. + deploy.value = NO_TOKEN; + setup.state = { + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Cancelled.", + log: "3/6 Pulling coolify...", + cancelled: true, + }; + render(); + + expect(screen.getByTestId("coolify-setup-warning").textContent).toContain( + "Docker and Coolify may be on the server", + ); + }); + + it("says nothing about a cancel that never got started", async () => { + // No output means the installer never ran, so the server is as it was. + // Telling that user Docker might be on it would be a new untruth. + deploy.value = NO_TOKEN; + setup.state = { + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Cancelled.", + log: "", + cancelled: true, + }; + render(); + + expect(screen.queryByTestId("coolify-setup-warning")).toBeNull(); + }); + + it("keeps a terminal run's notice when this app's status cannot be read", async () => { + // The run belongs to the machine. A status query that fails is about one + // app, and must not take the only note about what the run left behind. + deploy.value = { + status: undefined, + statusError: new Error("could not read the app"), + }; + setup.state = { + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Cancelled.", + log: "3/6 Pulling coolify...", + cancelled: true, + }; + render(); + + expect(screen.getByTestId("coolify-status-error")).toBeTruthy(); + expect(screen.getByTestId("coolify-setup-warning")).toBeTruthy(); + }); + + it("says what a cancelled run left on the server", async () => { + // A cancel hands the screen back to the card below rather than to the + // installer, so the panel that would otherwise carry this is not on + // screen at all. The domain is still pointing at the server either way. + deploy.value = SERVER_NO_TOKEN; + setup.state = { + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Cancelled.", + log: "", + cancelled: true, + warning: "Coolify may still be configured for 203.0.113.5.sslip.io.", + }; + render(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-warning").textContent).toContain( + "may still be configured", + ), + ); + // The card a cancel lands on, not the installer panel. + expect(screen.getByTestId("coolify-already-has-server")).toBeTruthy(); + expect(screen.queryByTestId("coolify-server-setup-stub")).toBeNull(); + }); + + it("says it on the token form too, where another window may be sitting", async () => { + // The run belongs to the machine, not to a window. One that had already + // moved on to entering a token still needs to know what was left behind. + deploy.value = NO_TOKEN; + setup.state = { + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Cancelled.", + log: "", + cancelled: true, + warning: "Coolify may still be configured for 203.0.113.5.sslip.io.", + }; + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "I already have Coolify installed" }), + ); + expect(screen.getByTestId("coolify-setup-warning").textContent).toContain( + "may still be configured", + ); + }); + + it("gives a cancelled run's warning a way off the screen", async () => { + // Nothing else dismisses a cancelled run — the panel's own Dismiss went + // with the panel — so without this it would sit there for good. + deploy.value = SERVER_NO_TOKEN; + setup.state = { + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Cancelled.", + log: "", + cancelled: true, + warning: "Coolify may still be configured for 203.0.113.5.sslip.io.", + }; + const user = userEvent.setup(); + render(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-dismiss-warning")).toBeTruthy(), + ); + await user.click(screen.getByTestId("coolify-setup-dismiss-warning")); + expect(dismissMock).toHaveBeenCalled(); + }); + + it("tells the installer which server it is already holding", async () => { + // The panel cannot ask: it never receives coolify status. Without this + // it offers an install the handler answers only with "sign out first". + deploy.value = SERVER_NO_TOKEN; + setup.state = { + type: "failed", + host: "203.0.113.5", + message: "boom", + cancelled: false, + log: "", + }; + render(); + + expect(screen.getByTestId("stub-held-server").textContent).toBe( + "http://203.0.113.5:8000", + ); + }); + + it("has nothing to sign out of when the run never got that far", async () => { + // A failure before the account was written leaves Dyad holding nothing, + // so the way to forget it is an offer to forget what does not exist. + deploy.value = NO_TOKEN; + setup.state = { + type: "failed", + host: "203.0.113.5", + message: "boom", + cancelled: false, + log: "", + }; + render(); + + expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(); + expect( + screen.queryByRole("button", { name: "Sign out of Coolify" }), + ).toBeNull(); + }); + + it("can be connected to from the card that refuses a new install", async () => { + // The address cannot be typed into here, so a form that never filled it + // in would leave signing out — which forgets the password Dyad is the + // only holder of — as the only way on from that card. + deploy.value = SERVER_NO_TOKEN; + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Enter an API token" }), + ); + await user.type(screen.getByTestId("coolify-token"), "1|abc"); + // A freshly installed server answers on http until it has a certificate, + // so this is the ordinary way through rather than an unusual one. + await user.click(screen.getByTestId("coolify-acknowledge-insecure")); + + expect( + (screen.getByTestId("coolify-save-token") as HTMLButtonElement).disabled, + ).toBe(false); + }); + + it("keeps the way back to a failure the installer is reporting", async () => { + // That screen carries the message, the output and the only control that + // clears the run, so the token form must not be a one-way door into it. + deploy.value = SERVER_NO_TOKEN; + setup.state = { + type: "failed", + host: "203.0.113.5", + message: "boom", + cancelled: false, + log: "", + }; + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "I already have Coolify installed" }), + ); + // Named for where it goes. Dyad set this server up, so offering to set + // one up "yet" describes somebody else's situation. + expect(screen.getByTestId("coolify-no-instance").textContent).toBe( + "Back to the installer", + ); + expect(screen.queryByText(/No Coolify server yet/i)).toBeNull(); + }); + + it("does not offer the installer as a way out of the token form", async () => { + deploy.value = SERVER_NO_TOKEN; + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Enter an API token" }), + ); + expect(screen.queryByTestId("coolify-no-instance")).toBeNull(); + }); +}); + +describe("where someone with no Coolify lands", () => { + it("offers to install one rather than asking for a token", async () => { + // The token form asks about a Coolify that already exists. Landing on it + // tells everyone else they are in the wrong place. + deploy.value = NO_TOKEN; + render(); + + expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(); + expect(screen.queryByTestId("coolify-instance-url")).toBeNull(); + }); + + it("reaches the token form from there, and back again", async () => { + deploy.value = NO_TOKEN; + const user = userEvent.setup(); + render(); + + await openTokenForm(user); + expect(screen.getByTestId("coolify-instance-url")).toBeTruthy(); + + await user.click(screen.getByTestId("coolify-no-instance")); + expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(); + }); + + it("puts the way out under what it knows, not against Install", async () => { + deploy.value = NO_TOKEN; + render(); + + const text = screen.getByTestId("coolify-server-setup-stub").textContent; + expect(text?.indexOf("Your Coolify server")).toBeLessThan( + text?.indexOf("I already have Coolify installed") ?? -1, + ); + }); + + it("still shows what it knows about a server with no token yet", async () => { + // Installing a server whose token could not be minted lands here, and the + // account Dyad made is the only way into it. + deploy.value = NO_TOKEN; + const user = userEvent.setup(); + render(); + + expect(screen.getByTestId("coolify-credentials-stub").textContent).toBe( + "Your Coolify server", + ); + await openTokenForm(user); + expect(screen.getByTestId("coolify-credentials-stub")).toBeTruthy(); + }); +}); + +describe("what the installer leaves on screen", () => { + const DONE = { + type: "done", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + result: { + dashboardUrl: "http://203.0.113.5:8000", + secure: false, + insecureReason: "No certificate arrived.", + adminEmail: "me@gmail.com", + adminPassword: "Abc123@xyz", + tokenStored: true, + apiEnabled: true, + tokenUnavailableReason: null, + version: "4.3.2", + }, + }; + + const CONNECTED = { + status: { + hasToken: true, + instanceUrl: "http://203.0.113.5:8000", + connection: null, + appUrl: null, + lastDeployedAt: null, + }, + discovery: { servers: [], projects: [] }, + }; + + it("keeps a finished install up even once a token exists", async () => { + // The install stores a token, so the panel would otherwise be replaced by + // the connected view — taking with it the only notice that the server + // ended up unencrypted. + setup.state = DONE; + deploy.value = CONNECTED; + render(); + + await waitFor(() => + expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(), + ); + }); + + it("moves on once the user has read it", async () => { + // Dismissing is what ends it, and that is recorded where the install is: + // in the main process, not in a flag this panel could lose. + setup.state = { type: "idle" }; + deploy.value = CONNECTED; + render(); + + await waitFor(() => + expect(screen.getByText("Where this app deploys")).toBeTruthy(), + ); + expect(screen.queryByTestId("coolify-server-setup-stub")).toBeNull(); + }); + + it("shows a running install rather than the form", async () => { + setup.state = { + type: "running", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + step: "installing", + log: "", + stopping: false, + }; + deploy.value = CONNECTED; + render(); + + await waitFor(() => + expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(), + ); + }); + + it("does not hold the panel open after a failure", async () => { + // A failure leaves the form up with the installer's output under it, and + // holding the view there made the token form unreachable. + setup.state = { + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Installing Coolify failed (exit 1).", + log: "dpkg: error", + cancelled: false, + }; + deploy.value = CONNECTED; + render(); + + // Waited for something positive: an absence is true before the answer + // arrives, so asserting only that would pass without proving anything. + await waitFor(() => + expect(screen.getByText("Where this app deploys")).toBeTruthy(), + ); + expect(screen.queryByTestId("coolify-server-setup-stub")).toBeNull(); + }); + + it("is about the server, so it does not change with the app", async () => { + // Deliberate: the install is about a machine, not about one app, and it + // stays until the user has read it and moved on. + setup.state = DONE; + deploy.value = CONNECTED; + const { rerender } = render(); + await waitFor(() => + expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(), + ); + + rerender(); + await waitFor(() => + expect(screen.getByTestId("coolify-server-setup-stub")).toBeTruthy(), + ); + }); +}); + describe("consent to an unencrypted address", () => { it("does not carry over to an address it was not given for", async () => { // Ticked for one plain-HTTP host, then the host is edited. The token would @@ -101,6 +850,7 @@ describe("consent to an unencrypted address", () => { }; const user = userEvent.setup(); render(); + await openTokenForm(user); const url = screen.getByTestId("coolify-instance-url"); await user.type(url, "http://box.local:8000"); @@ -132,6 +882,7 @@ describe("consent to an unencrypted address", () => { }; const user = userEvent.setup(); const { rerender } = render(); + await openTokenForm(user); const url = screen.getByTestId("coolify-instance-url"); await user.clear(url); @@ -250,6 +1001,37 @@ describe("naming the target in the connected view", () => { lastDeployedAt: 1, }; + it("still says what a cancelled run left behind", () => { + // The run belongs to the machine, not to a window or to one app. An app + // that already has somewhere to deploy is the easiest tab to be sitting + // on when a cancel lands, and it is the same server underneath. + setup.state = { + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Cancelled.", + log: "", + cancelled: true, + warning: "Coolify may still be configured for 203.0.113.5.sslip.io.", + }; + deploy.value = { + status: CONNECTED, + discovery: { + servers: [{ uuid: "srv-1", name: "production-box" }], + projects: [{ uuid: "prj-1", name: "storefront" }], + }, + }; + render(); + + expect(screen.getByTestId("coolify-setup-warning").textContent).toContain( + "may still be configured", + ); + }); + it("names the server and project once discovery has answered", () => { deploy.value = { status: CONNECTED, @@ -378,6 +1160,19 @@ describe("editing while the saved connection changes", () => { ).toBe("typed.example.com"); }); + it("keeps Cancel with Save, since they answer the same form", async () => { + // Up by the refresh control it read as cancelling something in progress. + deploy.value = statusWith(CONNECTION); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Edit" })); + const save = screen.getByTestId("coolify-save-connection"); + const cancel = screen.getByRole("button", { name: "Cancel" }); + + expect(save.parentElement).toBe(cancel.parentElement); + }); + it("puts the saved values back when the edit is abandoned", async () => { // Cancel only drops the flag; refilling is the effect's job. deploy.value = statusWith(CONNECTION); diff --git a/src/components/CoolifyConnector.tsx b/src/components/CoolifyConnector.tsx index 7dac5bc12c..f8c1aab39c 100644 --- a/src/components/CoolifyConnector.tsx +++ b/src/components/CoolifyConnector.tsx @@ -24,6 +24,11 @@ import { SelectValue, } from "@/components/ui/select"; import { ipc } from "@/ipc/types"; +import type { SetupSnapshot } from "@/ipc/types"; +import { CoolifyServerSetup } from "@/components/CoolifyServerSetup"; +import { useCoolifySetupSnapshot } from "@/hooks/useCoolifySetupSnapshot"; +import { CoolifyCredentials } from "@/components/CoolifyCredentials"; +import { CoolifySignOutDialog } from "@/components/CoolifySignOutDialog"; import { useLoadApp } from "@/hooks/useLoadApp"; import { useCoolifyDeploy } from "@/hooks/useCoolifyDeploy"; import { selectCoolifyDeployCapabilities } from "@/coolify_deploy/capabilities"; @@ -94,6 +99,11 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { disconnect, } = useCoolifyDeploy(appId); + // What the main process is doing with a server, if anything. Asked for + // rather than remembered: an install outlives this panel, and the panel + // being replaced — by a spinner, by a status error — must not lose it. + const { data: setupSnapshot } = useCoolifySetupSnapshot(); + const serverSelectId = useId(); const projectSelectId = useId(); const instanceUrlId = useId(); @@ -104,6 +114,10 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { // A saved connection otherwise hides the only form that can change the // server, project or domain — and the only way to sign out. const [isEditingConnection, setIsEditingConnection] = useState(false); + // Installing is where this lands, so the flag is the other way round: it + // records having said "I already have Coolify". + const [isEnteringToken, setIsEnteringToken] = useState(false); + const [isConfirmingSignOut, setIsConfirmingSignOut] = useState(false); const [token, setToken] = useState(""); const [serverUuid, setServerUuid] = useState(""); const [projectUuid, setProjectUuid] = useState(""); @@ -121,7 +135,13 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { // Leaving the edit refills, since this keys on that too. if (isEditingConnection) return; const connection = status?.connection; - setInstanceUrl(connection?.instanceUrl ?? status?.instanceUrl ?? ""); + // The server Dyad set up wins, because that is the case where the field is + // pinned and nothing else can fill it in. It is also the one address that + // is right there: a stored token names the instance it opens, which is not + // necessarily the machine whose account Dyad is holding. + setInstanceUrl( + status?.serverUrl ?? connection?.instanceUrl ?? status?.instanceUrl ?? "", + ); setServerUuid(connection?.serverUuid ?? ""); setProjectUuid(connection?.projectUuid ?? ""); setDomain(connection?.domain ?? ""); @@ -129,7 +149,13 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { // only on keystrokes left a tick given for a typed address sitting over the // remembered one this puts back. setAcknowledgedInsecure(false); - }, [appId, status?.connection, status?.instanceUrl, isEditingConnection]); + }, [ + appId, + status?.connection, + status?.instanceUrl, + status?.serverUrl, + isEditingConnection, + ]); // Only on an app change. Keying this to the connection would close the form // under the user whenever a background refetch handed back a new object. @@ -137,13 +163,135 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { setIsEditingConnection(false); }, [appId, status?.hasToken]); + // On both screens below. A server Dyad installed but could not mint a token + // for is named by its admin account and nothing else, and these are the two + // states that leaves the user in. + const newServerCredentials = ; + + // One element, not one per branch: rendering a second copy elsewhere in the + // tree would remount the panel and lose the result it is being kept for. + const setupState: SetupSnapshot = setupSnapshot ?? { type: "idle" }; + /** + * A failure the installer has something to say about. + * + * Cancelling is the user's own decision rather than a fault, so it carries + * no message and no way to clear itself — the machine simply rests in + * `failed` until the next run. Treating that as something to make room for + * would hand the installer back for good. + */ + const isReportingFailure = + setupState.type === "failed" && !setupState.cancelled; + + /** + * What a terminal run left for the user to know about. + * + * Two things, and the panel would otherwise say neither. A domain the run + * put on and could not take back off is theirs to clear; and a cancel that + * got as far as running the installer may have left Docker and Coolify on + * the server, which is why checking it again can answer that Coolify is + * already there. + * + * Here rather than in the installer panel because a cancelled run hands the + * screen back to the card below instead of to that panel, and above the + * status guards because this belongs to the machine rather than to one + * app's view of it. It carries its own way out: the panel's Dismiss goes + * with the panel, and nothing else clears a cancelled run. + */ + const stoppedAfterInstalling = + setupState.type === "failed" && + setupState.cancelled && + // The log only takes output from the installer onwards, so an empty one + // is a run that stopped before it could change anything. Saying Docker + // might be on the server would be a new untruth. + setupState.log.trim() !== ""; + const terminalNotice = + setupState.type === "failed" && + (setupState.warning || stoppedAfterInstalling) ? ( +
+
+ {stoppedAfterInstalling && ( +

+ Setting up Coolify was stopped. The installer had already started, + so Docker and Coolify may be on the server — if checking it again + says Coolify is already there, that is why. +

+ )} + {setupState.warning &&

{setupState.warning}

} +
+ +
+ ) : null; + + const serverSetup = ( + { + if (url) setInstanceUrl(url); + setIsEnteringToken(true); + }} + > + {newServerCredentials} + + {/* Last, under anything Dyad already knows about a Coolify: this is the + exit for the people the installer does not apply to, not another + control on it. */} +
+ +
+
+ ); + + // Before the token check: an install that is going on, or has something to + // say about how it went, outranks anything else this panel could show. Read + // from the main process rather than remembered here, so leaving the screen + // and coming back finds it again. + // A failure is not one of them: it leaves the form on screen with the + // installer's output under it, which is the panel the user would land on + // anyway, and holding the view there puts the token form out of reach. + if (setupState.type === "running" || setupState.type === "done") { + return ( +
+ {serverSetup} +
+ ); + } + // A query react-query has paused — the renderer is offline — is pending // with no data and no error, which is not a failure and must not read as // one. It is waiting, so it shows as waiting. if (appId === null || isStatusLoading || (!status && !statusError)) { return ( -
- Loading... +
+ {/* Above the two guards below, not inside the branches after them: a + run that ended belongs to the machine, and this app's status + being slow or unreadable is no reason to take back what it said. */} + {terminalNotice} +
+ Loading... +
); } @@ -153,20 +301,23 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { // so the panel stayed empty until a remount. The discovery query beside // it has said so properly all along. return ( -
-

Could not read this app's Coolify setup

-

{getErrorMessage(statusError)}

- +

Could not read this app's Coolify setup

+

{getErrorMessage(statusError)}

+ +
); } @@ -261,8 +412,93 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { ); - // --- Step 1: instance URL + API token --- + // --- Step 1: get a Coolify, or connect to one --- + // The one control that makes Dyad forget a Coolify: the address, the token, + // and the admin account for a server it set up. Available wherever Dyad + // holds any of those, because holding an account without a token is still + // holding a Coolify — and it is what has to be given up to reach a + // different one. + const signOut = ( + <> + + { + try { + await clearToken.mutateAsync(); + // The component is not remounted when the token goes, so without + // this the form comes back holding the credential just forgotten — + // and pressing Connect would silently store it again, which is + // the opposite of what signing out to rotate a token is for. + setToken(""); + setAcknowledgedInsecure(false); + toast.success( + "Signed out of Coolify. Your server keeps running and your apps keep their settings.", + ); + } catch (error) { + toast.error(getErrorMessage(error)); + } + }} + /> + + ); + if (!status.hasToken) { + // Installing comes first: the token form asks for an address and a token, + // which is a question about a Coolify that already exists. Landing on it + // told everyone else they were in the wrong place. + if (!isEnteringToken) { + // Dyad already has a server. Setting up another would replace the only + // copy of its password, so the way to one is through giving this one up + // deliberately rather than through starting again over the top of it. + // + // Not over a failure the installer is reporting. Its message, output and + // the way to clear it are all on that screen, and standing in front of + // it leaves the run both unexplained and impossible to dismiss. Retrying + // is also the ordinary thing to do next, and it lands on the same server. + if (status.serverUrl && !isReportingFailure) { + return ( +
+ {terminalNotice} +
+

Dyad already set up a server

+

+ Its details are below. Finish connecting to it, or sign out to + set up a different one — signing out forgets these. +

+
+ + {newServerCredentials} + {signOut} +
+ ); + } + return ( +
+ {terminalNotice} + {serverSetup} + {/* Installing again is refused while Dyad still holds an admin + password, and that refusal says to sign out first. Behind the + failure report is the one place it could be said without the way + to do it being on screen. */} + {status.serverUrl && signOut} +
+ ); + } + const trimmedUrl = instanceUrl.trim(); // A stock Coolify serves plain HTTP until it has a domain and certificate, // so this is the common case rather than an unusual one. @@ -281,6 +517,7 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { const isInsecure = hasUsableScheme && !isSecureInstanceUrl(trimmedUrl); return (
+ {terminalNotice}

Deploy this app to a Coolify instance you run. In Coolify, enable the API under Settings → Advanced → API Access, then create a token under @@ -295,6 +532,11 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { data-testid="coolify-instance-url" placeholder="https://coolify.example.com" value={instanceUrl} + // Fixed to the server Dyad set up while it holds that server's + // account. A token typed against another address would leave the + // account describing one machine and the token another, which is + // a pairing nothing downstream can tell apart from a way in. + readOnly={Boolean(status.serverUrl)} onChange={(e) => { setInstanceUrl(e.target.value); // The consent was given for the address that was on screen at @@ -303,6 +545,12 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { setAcknowledgedInsecure(false); }} /> + {status.serverUrl && ( +

+ The server Dyad set up. Sign out to connect to a different + Coolify. +

+ )}
@@ -335,6 +583,7 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { onCheckedChange={(checked) => setAcknowledgedInsecure(checked === true) } + data-testid="coolify-acknowledge-insecure" /> Connect anyway @@ -369,10 +618,55 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { )} Connect + + {newServerCredentials} + + {/* Back to the installer, for someone who came here by mistake, and + the only way back to a failure it is reporting. Not offered while + Dyad holds a server's account and has nothing to report: that + screen refuses to set up another anyway, and signing out is the + way there. */} + {status.serverUrl && !isReportingFailure ? ( +
{signOut}
+ ) : ( +
+

+ {/* The other way in here is a run that failed on a server Dyad + did set up. Offering to set one up "yet" over the top of it + describes somebody else's situation. */} + {status.serverUrl + ? "Dyad set up a server here and the run has something to say about it. " + : "No Coolify server yet? "} + + {status.serverUrl ? "." : " on a server you already have."} +

+
+ )}
); } + // Two things are on this screen and they are not the same thing: the + // Coolify the user connected to, which is theirs and outlives any app, and + // where this one app deploys. Kept apart so the instance and its way back + // in are readable without opening the app's settings. + const coolifySection = ( +
+
+
Coolify
+ {signOut} +
+ +
+ ); + // --- Step 2: server, project, domain --- if (!status.connection || isEditingConnection) { const projects = discovery?.projects ?? []; @@ -380,39 +674,18 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { // is isFetching and so also covers background refetches over a list the // user is already reading. const awaitingDiscovery = isDiscoveryPending; - // The only control that removes the stored instance URL and token. It used - // to live solely inside the discovery-error card, so rotating a token or - // moving to another instance meant first breaking discovery on purpose. - const disconnectEverything = ( - - ); const duplicateProjectName = projects.some( (p) => p.name.toLowerCase() === newProjectName.trim().toLowerCase(), ); return (
+ {terminalNotice} + {coolifySection} + +
+ Where this app deploys +
+ {discoveryError && (

Could not load servers and projects

@@ -452,23 +725,6 @@ export function CoolifyConnector({ appId }: { appId: number | null }) {
-
- {isEditingConnection && ( - - )} - {disconnectEverything} -
- {/* A project belongs to the Coolify instance, not to this app, so it is named and created on its own before anything is picked. */} {isEditingConnection && movingHost && ( @@ -636,100 +892,117 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { {insecureWarningBlock}
- + {isEditingConnection && ( + )} - Save - + ); } @@ -763,6 +1036,13 @@ export function CoolifyConnector({ appId }: { appId: number | null }) { return (
+ {terminalNotice} + {coolifySection} + +
+ Where this app deploys +
+ {belongsElsewhere && (

diff --git a/src/components/CoolifyCredentials.test.tsx b/src/components/CoolifyCredentials.test.tsx new file mode 100644 index 0000000000..412628a649 --- /dev/null +++ b/src/components/CoolifyCredentials.test.tsx @@ -0,0 +1,351 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/toast", () => ({ showError: vi.fn() })); + +const h = vi.hoisted(() => ({ revealCredentials: vi.fn() })); +vi.mock("@/ipc/types", () => ({ + ipc: { coolifySetup: { revealCredentials: h.revealCredentials } }, +})); + +const { queryKeys } = await import("@/lib/queryKeys"); +const { CoolifyCredentials: Panel } = await import("./CoolifyCredentials"); + +function CoolifyCredentials(props: { showTitle?: boolean }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + + + + ); +} + +const FULL = { + instance: { + url: "https://203.0.113.5.sslip.io", + apiToken: "1|abcdefghijklmnop", + }, + server: { + url: "https://203.0.113.5.sslip.io", + email: "me@gmail.com", + password: "Abc123@xyzAbc123@xyz", + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + h.revealCredentials.mockResolvedValue(FULL); +}); + +/** + * Waits for the read to have answered, not merely to have been asked. + * + * This panel renders nothing while the question is in flight and nothing + * when the answer is empty, so asserting on the first of those proves only + * that a promise had not resolved yet — it holds just as well with the guard + * for the second one taken out. + */ +async function settle() { + await waitFor(() => expect(h.revealCredentials).toHaveBeenCalled()); + // A turn of the event loop, not just the microtask queue: react-query + // carries a resolved read through to a render on a macrotask, so flushing + // microtasks alone lands back here with the panel still pending — which + // looks exactly like the empty answer these assertions are about. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function renderAndSettle() { + render(); + await waitFor(() => + expect(screen.getByTestId("coolify-credentials")).toBeTruthy(), + ); +} + +describe("what is on screen without asking", () => { + it("shows the details rather than hiding them behind a control", async () => { + // Made to click to discover Dyad even has these, most people never find + // out — and then signing out locks them out of their own server. + await renderAndSettle(); + + expect(screen.getByTestId("coolify-field-address").textContent).toBe( + FULL.instance.url, + ); + expect(screen.getByTestId("coolify-field-email").textContent).toBe( + FULL.server.email, + ); + }); + + it("includes the API token, not only the sign-in details", async () => { + // Signing out of Coolify in Dyad clears it, so without this the token is + // gone for good and the instance has to be set up again. + await renderAndSettle(); + expect(screen.getByTestId("coolify-field-api-token")).toBeTruthy(); + }); + + it("keeps the secrets masked until they are asked for", async () => { + // Showing the details is not the same as showing the secrets: these sit + // in a panel someone may have open while screen sharing. + await renderAndSettle(); + + expect(screen.getByTestId("coolify-field-password").textContent).toMatch( + /^•+$/, + ); + expect(screen.getByTestId("coolify-field-api-token").textContent).toMatch( + /^•+$/, + ); + }); + + it("does not mask what is not secret", async () => { + await renderAndSettle(); + expect(screen.queryByRole("button", { name: "Show Address" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Show Email" })).toBeNull(); + }); +}); + +describe("revealing one value", () => { + it("shows the password when asked, and hides it again", async () => { + const user = userEvent.setup(); + await renderAndSettle(); + + await user.click(screen.getByRole("button", { name: "Show Password" })); + expect(screen.getByTestId("coolify-field-password").textContent).toBe( + FULL.server.password, + ); + + await user.click(screen.getByRole("button", { name: "Hide Password" })); + expect(screen.getByTestId("coolify-field-password").textContent).toMatch( + /^•+$/, + ); + }); + + it("reveals each value on its own", async () => { + // Showing the password should not put the token on screen too. + const user = userEvent.setup(); + await renderAndSettle(); + + await user.click(screen.getByRole("button", { name: "Show Password" })); + + expect(screen.getByTestId("coolify-field-api-token").textContent).toMatch( + /^•+$/, + ); + }); +}); + +describe("naming the section", () => { + it("names a server Dyad installed but has no token for", async () => { + // Reached by installing a server whose API token could not be minted, so + // the account is all Dyad has for it. + h.revealCredentials.mockResolvedValue({ ...FULL, instance: null }); + render(); + + await waitFor(() => + expect(screen.getByText("Your Coolify server")).toBeTruthy(), + ); + }); + + it("leaves no heading over nothing", async () => { + // The caller cannot know there is anything to show until this has asked. + h.revealCredentials.mockResolvedValue({ instance: null, server: null }); + render(); + + await settle(); + expect(screen.queryByText("Your Coolify server")).toBeNull(); + }); +}); + +describe("while the read is still going", () => { + it("says so rather than leaving the caller's heading over nothing", async () => { + // Every caller introduces this panel as the details it is about to show. + // Rendering nothing until the answer lands leaves "Its details are below" + // with nothing below it, which reads as Dyad holding nothing at all. + h.revealCredentials.mockReturnValue(new Promise(() => {})); + render(); + + expect( + await screen.findByTestId("coolify-credentials-loading"), + ).toBeTruthy(); + expect(screen.queryByTestId("coolify-credentials")).toBeNull(); + }); + + it("gives way once the answer arrives", async () => { + render(); + + await settle(); + expect(screen.queryByTestId("coolify-credentials-loading")).toBeNull(); + expect(screen.getByTestId("coolify-credentials")).toBeTruthy(); + }); +}); + +describe("a password Dyad holds but cannot read", () => { + it("says so rather than showing a server that never had one", async () => { + // readSettings drops a password it cannot decrypt and keeps the account. + // Left as a missing row, that reads as there never having been one — and + // the obvious next move from there is the sign-out that discards it. + h.revealCredentials.mockResolvedValue({ + instance: null, + server: { + url: "http://203.0.113.5:8000", + email: "me@gmail.com", + password: null, + }, + }); + render(); + + await settle(); + expect( + screen.getByTestId("coolify-credentials-locked-password").textContent, + ).toContain("cannot read it on this machine"); + expect(screen.queryByTestId("coolify-field-password")).toBeNull(); + }); + + it("says it on the merged panel too", async () => { + // One address for both, so the fields are merged — the same gap, on the + // layout the connected view uses. + h.revealCredentials.mockResolvedValue({ + instance: { url: "http://203.0.113.5:8000", apiToken: "1|abc" }, + server: { + url: "http://203.0.113.5:8000", + email: "me@gmail.com", + password: null, + }, + }); + render(); + + await settle(); + expect( + screen.getByTestId("coolify-credentials-locked-password"), + ).toBeTruthy(); + expect(screen.queryByTestId("coolify-field-password")).toBeNull(); + }); +}); + +describe("two servers that are not the same server", () => { + it("keeps each address with what it opens", async () => { + // Installed a server whose token could not be minted, then connected to a + // different Coolify. One address over both would read as a way into the + // connected one that is not one. + h.revealCredentials.mockResolvedValue({ + instance: { + url: "https://someone-elses.example.com", + apiToken: "1|other", + }, + server: { + url: "http://203.0.113.5:8000", + email: "me@gmail.com", + password: "Abc123@xyz", + }, + }); + await renderAndSettle(); + + // One id each, so a lookup for the address reaches one thing rather than + // two — both blocks carry one. + expect(screen.getByTestId("coolify-field-server-address")).toBeTruthy(); + expect(screen.getByTestId("coolify-field-instance-address")).toBeTruthy(); + + const forServer = screen.getByTestId("coolify-credentials-server"); + const forInstance = screen.getByTestId("coolify-credentials-instance"); + expect(forServer.textContent).toContain("http://203.0.113.5:8000"); + expect(forServer.textContent).not.toContain("someone-elses"); + expect(forInstance.textContent).toContain("someone-elses.example.com"); + // The password belongs to the machine Dyad built, and stays with it. + // Asserted on the field rather than on the text: a secret renders as + // bullets until it is revealed, so looking for the value itself passes + // wherever the row is put. + expect(screen.getByTestId("coolify-field-server-password")).toBeTruthy(); + expect(screen.queryByTestId("coolify-field-instance-password")).toBeNull(); + }); + + it("shows one block when both describe the same address", async () => { + await renderAndSettle(); + + expect(screen.queryByTestId("coolify-credentials-server")).toBeNull(); + expect(screen.queryByTestId("coolify-credentials-instance")).toBeNull(); + expect(screen.getAllByTestId(/^coolify-field-address$/)).toHaveLength(1); + }); +}); + +describe("a read that did not answer", () => { + it("keeps details it already has when a later read fails", async () => { + // The panel refetches on window focus once its data is a minute old, and + // production does not retry. Standing that failure in front of a password + // Dyad holds the only copy of takes it off screen with nothing to copy — + // and in the sign-out dialog it goes just as the user is asked to confirm + // they have saved it. + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + render( + + + , + ); + await waitFor(() => + expect(screen.getByTestId("coolify-field-password")).toBeTruthy(), + ); + + h.revealCredentials.mockRejectedValue(new Error("keychain locked")); + await client.refetchQueries({ queryKey: queryKeys.coolify.credentials }); + // The refetch settling is not the panel having re-rendered on it, and + // reading the screen in between shows what was there before either way. + await waitFor(() => expect(h.revealCredentials).toHaveBeenCalledTimes(2)); + + expect(screen.getByTestId("coolify-field-password")).toBeTruthy(); + expect(screen.queryByTestId("coolify-credentials-unreadable")).toBeNull(); + }); + + it("says so rather than rendering nothing", async () => { + // Callers introduce this panel as the details they are about to show, so + // a blank space where they should be reads as Dyad holding nothing. + h.revealCredentials.mockRejectedValue(new Error("keychain locked")); + render(); + + await waitFor(() => + expect(screen.getByTestId("coolify-credentials-unreadable")).toBeTruthy(), + ); + }); + + it("stays silent when there is genuinely nothing stored", async () => { + // Signed out, or connected by pasting a token. Saying a read failed here + // would report a problem that did not happen. + h.revealCredentials.mockResolvedValue({ instance: null, server: null }); + const { container } = render(); + + // Waiting for the call is not waiting for the answer, and a pending query + // renders nothing whatever this branch does. Settling first is what makes + // a wrong branch here visible. + await waitFor(() => expect(h.revealCredentials).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(screen.queryByTestId("coolify-credentials-unreadable")).toBeNull(); + expect(container.textContent).toBe(""); + }); +}); + +describe("an instance Dyad did not set up", () => { + it("renders nothing rather than an empty heading", async () => { + // Connected by pasting a token: no account Dyad created, no address it + // chose. A panel of blanks would read as something having failed. + h.revealCredentials.mockResolvedValue({ instance: null, server: null }); + const { container } = render(); + + await settle(); + expect(screen.queryByTestId("coolify-credentials")).toBeNull(); + expect(container.textContent).toBe(""); + }); + + it("still shows a token the user pasted themselves", async () => { + h.revealCredentials.mockResolvedValue({ + instance: { url: "https://coolify.example.com", apiToken: "1|theirs" }, + server: null, + }); + await renderAndSettle(); + + expect(screen.getByTestId("coolify-field-api-token")).toBeTruthy(); + expect(screen.queryByTestId("coolify-field-password")).toBeNull(); + }); +}); diff --git a/src/components/CoolifyCredentials.tsx b/src/components/CoolifyCredentials.tsx new file mode 100644 index 0000000000..2e23bd3097 --- /dev/null +++ b/src/components/CoolifyCredentials.tsx @@ -0,0 +1,264 @@ +import { useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Eye, EyeOff, Copy, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ipc } from "@/ipc/types"; +import { showError } from "@/lib/toast"; +import { queryKeys } from "@/lib/queryKeys"; + +/** + * The way into a server Dyad set up. + * + * Dyad invents the admin password and mints the API token, so it is the only + * thing that knows either. Without somewhere to read them, a machine the user + * owns has no way in they can see. + * + * Shown rather than hidden behind a control: these belong to the user, and + * making them click to discover that Dyad even has them means most people + * never find out. The values themselves stay masked until asked for, which is + * the part worth a click. + */ + +function Field({ + label, + value, + secret, + idPrefix, +}: { + label: string; + value: string; + secret?: boolean; + /** + * Told apart from the same label in the other block, and only when both + * are on screen. Both carry an address, so an id naming just the label + * would be two things at once — which any lookup for it reaches + * ambiguously. One block alone has nothing to be confused with. + */ + idPrefix?: string; +}) { + const [shown, setShown] = useState(false); + const [copied, setCopied] = useState(false); + const resetTimer = useRef>(undefined); + useEffect(() => () => clearTimeout(resetTimer.current), []); + const id = `${idPrefix ? `${idPrefix}-` : ""}${label + .toLowerCase() + .replace(/\s+/g, "-")}`; + return ( +

+ {label} +
+ + {!secret || shown ? value : "•".repeat(Math.min(value.length, 16))} + + {secret && ( + + )} + +
+
+ ); +} + +/** + * Said rather than left blank. + * + * readSettings drops a password it cannot decrypt and keeps the account, so + * the absence is Dyad holding one it cannot open — not a server that never + * had one. Left to the row simply not appearing, the two look identical, and + * only one of them makes signing out a safe thing to do next. + */ +function LockedPassword() { + return ( +

+ Dyad is holding an admin password for this server but cannot read it on + this machine. +

+ ); +} + +export function CoolifyCredentials({ + showTitle, +}: { showTitle?: boolean } = {}) { + // Not held after this leaves the screen: these are the keys to the user's + // server, and there is no reason for them to sit in a cache once nothing is + // showing them. + const { + data: credentials, + isError, + isPending, + } = useQuery({ + queryKey: queryKeys.coolify.credentials, + queryFn: () => ipc.coolifySetup.revealCredentials(), + gcTime: 0, + }); + + // Said rather than left blank, the same reason the failure below is said: + // callers introduce this panel as the details they are about to show, so a + // blank where those belong reads as Dyad holding nothing rather than as a + // read still going. Local and quick, which is why this is a line and not a + // skeleton — but quick is not instant on a cold start. + if (isPending && !isError) { + return ( +

+ Looking up what Dyad has stored… +

+ ); + } + + // Only when there is nothing to show. A read that fails over details + // already in hand — the refetch on window focus, which production does not + // retry — still leaves them readable, and taking a password Dyad holds the + // only copy of off the screen to report the refresh would be the worse + // trade. + if (isError && !credentials) { + return ( +

+ Dyad could not read what it has stored for this Coolify. +

+ ); + } + if (!credentials) return null; + + const { instance, server } = credentials; + // Nothing stored at all — signed out, or never connected. A panel of blanks + // would read as something having failed. + if (!instance && !server) return null; + // The usual case: Dyad set the server up and is connected to it. Merged on + // the address matching exactly, never on a guess at two spellings of one + // machine — guessing wrong the other way shows two blocks with two correct + // addresses, which is a moment's confusion rather than a wrong password. + const isOneServer = + instance !== null && server !== null && instance.url === server.url; + // Only when there are two blocks to tell apart. Over a lone block they name + // something nothing is being distinguished from. + const showsBoth = instance !== null && server !== null; + + return ( +
+ {/* Kept inside so a caller cannot leave a heading over nothing when + there is nothing to show. */} + {showTitle && ( +
Your Coolify server
+ )} + + {/* A password Dyad holds but cannot decrypt comes back absent, which + renders as a server that never had one — and the way that reads, + the obvious next move is the sign-out that discards it for good. + The dialog says this; nowhere else did. */} + {isOneServer ? ( + <> + + + {server.password ? ( + + ) : ( + + )} + {instance.apiToken && ( + + )} + + ) : ( + <> + {server && ( +
+ {showsBoth && ( +
+ The server Dyad set up +
+ )} + + + {server.password ? ( + + ) : ( + + )} +
+ )} + {instance && ( +
+ {showsBoth && ( +
+ The Coolify Dyad is connected to +
+ )} + + {instance.apiToken && ( + + )} +
+ )} + + )} +
+ ); +} diff --git a/src/components/CoolifyServerSetup.test.tsx b/src/components/CoolifyServerSetup.test.tsx new file mode 100644 index 0000000000..98b418f61b --- /dev/null +++ b/src/components/CoolifyServerSetup.test.tsx @@ -0,0 +1,1084 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/queryKeys"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { SETUP_MACHINE_REPORTED } from "@/ipc/types/coolify_setup"; + +const toastMock = vi.hoisted(() => ({ + warning: vi.fn(), + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), +})); +vi.mock("sonner", () => ({ toast: toastMock })); +vi.mock("@/lib/toast", () => ({ showError: h.showError })); + +const h = vi.hoisted(() => ({ + showError: vi.fn(), + getServerKey: vi.fn(), + snapshot: vi.fn(), + dismiss: vi.fn(), + acceptInsecureToken: vi.fn(), + changedListeners: [] as Array<(state: unknown) => void>, + inspect: vi.fn(), + run: vi.fn(), + cancel: vi.fn(), +})); + +/** What the main process says is going on, pushed as it would be in the app. */ +const IDLE = { type: "idle" } as const; +function push(state: unknown) { + h.changedListeners.forEach((fn) => fn(state)); +} +const runningState = (over: Record = {}) => ({ + type: "running", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + step: "installing", + log: "", + stopping: false, + ...over, +}); + +vi.mock("@/ipc/types", () => ({ + ipc: { + coolifySetup: { + getServerKey: h.getServerKey, + inspect: h.inspect, + run: h.run, + cancel: h.cancel, + snapshot: h.snapshot, + dismiss: h.dismiss, + acceptInsecureToken: h.acceptInsecureToken, + }, + events: { + coolifySetup: { + onChanged: (listener: (state: unknown) => void) => { + h.changedListeners.push(listener); + return () => {}; + }, + }, + }, + }, +})); + +const { CoolifyServerSetup } = await import("./CoolifyServerSetup"); + +function renderPanel( + onUseExisting = vi.fn(), + props: { heldServerUrl?: string | null } = {}, +) { + const client = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }); + const invalidate = vi.spyOn(client, "invalidateQueries"); + return { + invalidate, + ...render( + + +
+ + , + ), + }; +} + +const PUBLIC_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA dyad-server-access"; + +beforeEach(() => { + vi.clearAllMocks(); + h.changedListeners.length = 0; + h.snapshot.mockResolvedValue(IDLE); + h.dismiss.mockResolvedValue(undefined); + h.acceptInsecureToken.mockResolvedValue(undefined); + h.getServerKey.mockResolvedValue({ publicKey: PUBLIC_KEY }); + h.cancel.mockResolvedValue(undefined); + h.inspect.mockResolvedValue({ + ready: true, + reason: null, + alreadyInstalled: false, + memoryMb: 1967, + hostFingerprint: "SHA256:3FQS9D0B0DVizoYtw1hNV09EClubwWqRUXoFnRTu6nA", + }); +}); + +/** Install is offered only for a server Dyad has looked at. */ +async function checkServer(user: ReturnType) { + await user.click(screen.getByTestId("coolify-setup-inspect")); + await waitFor(() => + expect(screen.getByTestId("coolify-setup-inspection")).toBeTruthy(), + ); +} + +describe("the key the user has to install", () => { + it("shows it first, because nothing else can happen until it is added", async () => { + renderPanel(); + await waitFor(() => + expect(screen.getByTestId("coolify-setup-public-key").textContent).toBe( + PUBLIC_KEY, + ), + ); + }); +}); + +describe("the admin address", () => { + it("warns about a domain Coolify will refuse, while it is being typed", async () => { + // Coolify resolves the domain when it seeds the account, so an address on + // a reserved domain fails after a multi-minute install rather than before. + const user = userEvent.setup(); + renderPanel(); + await user.type( + screen.getByTestId("coolify-setup-email"), + "admin@dyad.test", + ); + + expect(screen.getByText(/receive mail at/)).toBeTruthy(); + }); + + it("says nothing about an ordinary address", async () => { + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + + expect(screen.queryByText(/receive mail at/)).toBeNull(); + }); + + it("will not start an install it knows the address fails", async () => { + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type( + screen.getByTestId("coolify-setup-email"), + "admin@dyad.test", + ); + // Checked, so what refuses below is the address rather than the check the + // button is otherwise waiting for. + await checkServer(user); + + expect( + screen.getByTestId("coolify-setup-install").hasAttribute("disabled"), + ).toBe(true); + }); +}); + +describe("checking the server first", () => { + it("shows the fingerprint, so it can be compared before anything is sent", async () => { + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.click(screen.getByTestId("coolify-setup-inspect")); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-inspection")).toBeTruthy(), + ); + expect(screen.getByText(/SHA256:3FQS9D0B/)).toBeTruthy(); + }); + + it("blocks the install when the server cannot take one", async () => { + h.inspect.mockResolvedValue({ + ready: false, + reason: "This server already has Coolify on it.", + alreadyInstalled: true, + memoryMb: 1967, + hostFingerprint: null, + }); + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await user.click(screen.getByTestId("coolify-setup-inspect")); + + await waitFor(() => + expect(screen.getByText(/already has Coolify/)).toBeTruthy(), + ); + expect( + screen.getByTestId("coolify-setup-install").hasAttribute("disabled"), + ).toBe(true); + }); +}); + +describe("while it runs", () => { + // The panel is a view of what the main process says is going on. It keeps + // none of this itself, so leaving the screen and coming back finds it again. + + it("says which step it is on rather than only spinning", async () => { + // The install takes minutes; a spinner with no label is indistinguishable + // from a hang, and this is the screen people stare at. + h.snapshot.mockResolvedValue(runningState({ step: "installing" })); + renderPanel(); + + await waitFor(() => + expect(screen.getByText("Installing Coolify")).toBeTruthy(), + ); + }); + + it("shows the installer's own output", async () => { + h.snapshot.mockResolvedValue( + runningState({ log: "3/6 Pulling Docker images..." }), + ); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-log").textContent).toContain( + "3/6 Pulling Docker images...", + ), + ); + }); + + it("offers a way to stop", async () => { + h.snapshot.mockResolvedValue(runningState()); + const user = userEvent.setup(); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-cancel")).toBeTruthy(), + ); + await user.click(screen.getByTestId("coolify-setup-cancel")); + expect(h.cancel).toHaveBeenCalled(); + }); + + it("says it is stopping once asked, rather than offering again", async () => { + h.snapshot.mockResolvedValue(runningState({ stopping: true })); + renderPanel(); + + await waitFor(() => + expect( + (screen.getByTestId("coolify-setup-cancel") as HTMLButtonElement) + .disabled, + ).toBe(true), + ); + }); + + it("shows a run this window did not start", async () => { + // The whole point of holding this in the main process: the install was + // started by another window, or by this one before it was replaced. + h.snapshot.mockResolvedValue(runningState({ step: "securing" })); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-running")).toBeTruthy(), + ); + expect(screen.getByText("Setting up HTTPS")).toBeTruthy(); + }); + + it("keeps up as the run moves on", async () => { + renderPanel(); + await waitFor(() => + expect(screen.getByTestId("coolify-server-setup")).toBeTruthy(), + ); + + push(runningState({ step: "waiting-for-dashboard" })); + + await waitFor(() => + expect(screen.getByText("Waiting for Coolify to start")).toBeTruthy(), + ); + }); +}); + +describe("what it refuses before starting", () => { + it("says so while a domain is being typed, not minutes later", async () => { + // The same shape the installer refuses. Left to the guard, it arrives + // after the install as the reason HTTPS did not happen. + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await user.type( + screen.getByTestId("coolify-setup-domain"), + "coolify.example.com:8000", + ); + + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(true); + }); + + it("will not install onto a server it has not looked at", async () => { + // The check is what puts the fingerprint in front of the user. Installing + // without it means trusting whatever answers the address with the admin + // password and a token, and never showing them what they trusted. + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(true); + + await checkServer(user); + + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(false); + }); + + it("accepts an ordinary domain", async () => { + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await user.type( + screen.getByTestId("coolify-setup-domain"), + "coolify.example.com", + ); + await checkServer(user); + + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(false); + }); +}); + +describe("a re-check that does not finish", () => { + it("leaves no verdict behind, and no install to press", async () => { + // The main process keeps the last finished check's pass on purpose, so + // this is the only thing between a check that never came back and an + // install that sends the admin password to whatever answered. + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await checkServer(user); + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(false); + + h.inspect.mockRejectedValueOnce(new Error("connection reset")); + await user.click(screen.getByTestId("coolify-setup-inspect")); + + await waitFor(() => + expect(screen.queryByTestId("coolify-setup-inspection")).toBeNull(), + ); + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(true); + }); +}); + +describe("an answer about a server the user has moved on from", () => { + it("does not show one machine's check against another's address", async () => { + // The answer arrives after a round trip. By then the address in the field + // may be a different machine, whose Install button this verdict would + // otherwise disable. + let answer!: (checks: unknown) => void; + h.inspect.mockReturnValue( + new Promise((resolve) => { + answer = resolve; + }), + ); + const user = userEvent.setup(); + renderPanel(); + const host = screen.getByTestId("coolify-setup-host"); + await user.type(host, "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await user.click(screen.getByTestId("coolify-setup-inspect")); + + await user.clear(host); + await user.type(host, "198.51.100.9"); + answer({ + ready: false, + reason: "This server already has Coolify on it.", + alreadyInstalled: true, + memoryMb: 1967, + hostFingerprint: "SHA256:aaa", + }); + + await waitFor(() => expect(h.inspect).toHaveBeenCalled()); + // The verdict belonged to the address it was asked about. Shown here it + // would say this machine already has Coolify on it, which nobody checked. + expect(screen.queryByTestId("coolify-setup-inspection")).toBeNull(); + }); +}); + +describe("catching up with a run this window did not start", () => { + it("is listening before it asks what is going on", async () => { + // The answer takes a round trip. A run that finishes inside it is only + // heard about if the listener was already there when the question left. + let listenersWhenAsked = -1; + h.snapshot.mockImplementation(async () => { + listenersWhenAsked = h.changedListeners.length; + return IDLE; + }); + renderPanel(); + + await waitFor(() => expect(h.snapshot).toHaveBeenCalled()); + expect(listenersWhenAsked).toBeGreaterThan(0); + }); + + it("does not let a late answer undo what it was told meanwhile", async () => { + // The read says "installing" because that was true when it was asked. By + // the time it lands the run is over, and letting it win would put the + // panel back on a step the run has left, under a Cancel button for + // something that is no longer going. + let answer: (state: unknown) => void = () => {}; + h.snapshot.mockReturnValue( + new Promise((resolve) => { + answer = resolve; + }), + ); + renderPanel(); + + await waitFor(() => expect(h.changedListeners.length).toBeGreaterThan(0)); + push({ + type: "done", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + result: { + dashboardUrl: "https://203.0.113.5.sslip.io", + secure: true, + insecureReason: null, + adminEmail: "me@gmail.com", + adminPassword: "Abc123@xyz", + tokenStored: true, + apiEnabled: true, + tokenUnavailableReason: null, + version: "4.3.2", + }, + }); + answer(runningState()); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-done")).toBeTruthy(), + ); + expect(screen.queryByTestId("coolify-setup-running")).toBeNull(); + }); +}); + +describe("when the panel cannot tell what is going on", () => { + it("says so, and asking again clears it", async () => { + // Install is disabled while this is unknown, and a disabled control with + // no explanation reads as broken. + h.snapshot.mockRejectedValue(new Error("no answer")); + const user = userEvent.setup(); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-snapshot-error")).toBeTruthy(), + ); + expect(screen.getByTestId("coolify-setup-install")).toHaveProperty( + "disabled", + true, + ); + + // The way out has to work, not just be on screen. + h.snapshot.mockResolvedValue({ type: "idle" }); + await user.click(screen.getByRole("button", { name: "Try again" })); + + await waitFor(() => + expect(screen.queryByTestId("coolify-setup-snapshot-error")).toBeNull(), + ); + }); +}); + +describe("a failure with nothing to show for it", () => { + it("can still be dismissed", async () => { + // Connection and preflight refusals never reach the installer, so they + // carry no output. Dismiss has to sit outside the log, or these failures + // would have no way to clear them. + h.snapshot.mockResolvedValue({ + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Could not reach the server (ECONNREFUSED).", + log: "", + cancelled: false, + }); + const user = userEvent.setup(); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-failed-message")).toBeTruthy(), + ); + expect(screen.queryByTestId("coolify-setup-failed-log")).toBeNull(); + + await user.click(screen.getByTestId("coolify-setup-dismiss-failure")); + expect(h.dismiss).toHaveBeenCalled(); + }); +}); + +describe("a failure that will not go away", () => { + it("can be dismissed, since it follows the user everywhere", async () => { + // The state is the main process's, so the last failure shows in every + // app's panel and every window until something clears it. + h.snapshot.mockResolvedValue({ + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Installing Coolify failed (exit 1).", + log: "dpkg: error", + cancelled: false, + }); + const user = userEvent.setup(); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-dismiss-failure")).toBeTruthy(), + ); + await user.click(screen.getByTestId("coolify-setup-dismiss-failure")); + expect(h.dismiss).toHaveBeenCalled(); + }); +}); + +describe("pressing Install", () => { + it("does not offer a second press while the first is in flight", async () => { + // The panel still shows the form until the broadcast lands, and a second + // request is refused as a second setup. + h.run.mockReturnValue(new Promise(() => {})); + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await checkServer(user); + await user.click(screen.getByTestId("coolify-setup-install")); + // The press has to have landed, or what is disabled below is the button + // waiting for a check rather than the run it started. + expect(h.run).toHaveBeenCalledTimes(1); + + await waitFor(() => + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(true), + ); + }); + + it("does not offer an install the handler will refuse", async () => { + // A run that failed after the account was written leaves this panel up, + // and a fresh check would otherwise re-enable Install — for a press that + // can only come back as "sign out first". + const user = userEvent.setup(); + renderPanel(vi.fn(), { heldServerUrl: "http://203.0.113.5:8000" }); + await user.type(screen.getByTestId("coolify-setup-host"), "198.51.100.9"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await checkServer(user); + + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(true); + // And says why, next to the way out of it. + expect( + screen.getByTestId("coolify-setup-holds-account").textContent, + ).toContain("Sign out of Coolify"); + }); + + it("will not install against a verdict a new check has replaced", async () => { + // Checking again is how the user reacts to changing the address or + // suspecting the machine moved. Until the new answer lands there is no + // fingerprint on screen to have agreed to, and installing would hand + // root and a fresh password to whatever now answers. + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await checkServer(user); + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(false); + + // A second check that has not answered yet. + h.inspect.mockReturnValue(new Promise(() => {})); + await user.click(screen.getByTestId("coolify-setup-inspect")); + + await waitFor(() => + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(true), + ); + expect(screen.queryByTestId("coolify-setup-inspection")).toBeNull(); + }); + + it("says the press landed while the first connect is still going", async () => { + // Disabled on its own reads as the button having refused. The connect + // behind it can take seconds with nothing else on screen moving, and + // Check server already says so for the same wait. + h.run.mockReturnValue(new Promise(() => {})); + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await checkServer(user); + await user.click(screen.getByTestId("coolify-setup-install")); + + await waitFor(() => + expect( + screen + .getByTestId("coolify-setup-install") + .querySelector(".animate-spin"), + ).toBeTruthy(), + ); + }); + + it("does not start when the key could not be read", async () => { + // The key is what the server trusts; without it the install cannot work, + // and the panel already says so. + h.getServerKey.mockRejectedValue(new Error("key file is corrupt")); + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + // Checked, so what is disabled below is the missing key rather than the + // check the button is otherwise waiting for. + await checkServer(user); + + await waitFor(() => + expect( + (screen.getByTestId("coolify-setup-install") as HTMLButtonElement) + .disabled, + ).toBe(true), + ); + }); +}); + +describe("the key the server needs", () => { + it("offers a way to try again when it cannot be read", async () => { + // Left as "Generating…" forever, there is nothing to read and nothing to + // press, and the whole screen depends on it. + h.getServerKey.mockRejectedValue(new Error("key file is corrupt")); + renderPanel(); + + await waitFor(() => + expect(screen.getByRole("button", { name: "Try again" })).toBeTruthy(), + ); + }); +}); + +describe("the way out of the installer", () => { + it("carries what the screen below adds, under the form", async () => { + // The way out for someone who already has Coolify lives there. + renderPanel(); + expect(screen.getByTestId("beneath")).toBeTruthy(); + }); + + it("drops it once the install is running", async () => { + // That screen has one thing left to say, and a link away from it is not + // it. Nothing here is a next step while the work is going on. + h.snapshot.mockResolvedValue(runningState()); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-running")).toBeTruthy(), + ); + expect(screen.queryByTestId("beneath")).toBeNull(); + }); + + it("asks for the admin email before the optional domain", async () => { + // A required field under an optional one reads as optional too. + renderPanel(); + const text = screen.getByTestId("coolify-server-setup").textContent ?? ""; + expect(text.indexOf("Email for the Coolify admin account")).toBeLessThan( + text.indexOf("Domain (optional)"), + ); + }); +}); + +describe("when the user stops it", () => { + it("does not report cancelling as a failure", async () => { + // Cancelling is the user asking for the work to stop. The installer's + // output under "What the server reported" reads as a fault. + h.snapshot.mockResolvedValue({ + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Cancelled.", + log: "3/6 Pulling...", + cancelled: true, + }); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-server-setup")).toBeTruthy(), + ); + expect(screen.queryByTestId("coolify-setup-failed-log")).toBeNull(); + }); + + it("does not raise a red error for a cancel", async () => { + // The flow rethrows the cancellation, so it arrives here as a rejection — + // and reporting it says something went wrong while the screen says + // nothing did. + h.run.mockRejectedValue( + Object.assign(new DyadError("Cancelled.", DyadErrorKind.UserCancelled), { + code: SETUP_MACHINE_REPORTED, + }), + ); + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await checkServer(user); + await user.click(screen.getByTestId("coolify-setup-install")); + + await waitFor(() => expect(h.run).toHaveBeenCalled()); + expect(h.showError).not.toHaveBeenCalled(); + }); + + it("says what the machine never saw, rather than swallowing it", async () => { + // The IPC layer turns down bad input before the handler is reached, so + // that error carries no mark and the machine has no state for it. Left + // unsaid, pressing Install would do nothing at all. + h.run.mockRejectedValue( + new DyadError( + "[coolify-setup:run] Invalid input", + DyadErrorKind.Validation, + ), + ); + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await checkServer(user); + await user.click(screen.getByTestId("coolify-setup-install")); + + await waitFor(() => expect(h.showError).toHaveBeenCalled()); + }); + + it("still reports a refusal to start", async () => { + // The shape the handler actually refuses with: nothing reached the + // machine, so the error carries no mark and the panel says it out loud. + h.run.mockRejectedValue( + new DyadError( + "A server is already being set up.", + DyadErrorKind.Precondition, + ), + ); + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await checkServer(user); + await user.click(screen.getByTestId("coolify-setup-install")); + + await waitFor(() => expect(h.showError).toHaveBeenCalled()); + }); + + it("leaves a failed install to the panel rather than saying it twice", async () => { + // The failure block carries the installer's own output; a toast beside it + // repeats the same event with less to show. + h.run.mockRejectedValue( + Object.assign( + new DyadError( + "Installing Coolify failed (exit 1).", + DyadErrorKind.External, + ), + { code: SETUP_MACHINE_REPORTED }, + ), + ); + const user = userEvent.setup(); + renderPanel(); + await user.type(screen.getByTestId("coolify-setup-host"), "203.0.113.5"); + await user.type(screen.getByTestId("coolify-setup-email"), "me@gmail.com"); + await checkServer(user); + await user.click(screen.getByTestId("coolify-setup-install")); + + await waitFor(() => expect(h.run).toHaveBeenCalled()); + expect(h.showError).not.toHaveBeenCalled(); + }); + + it("still shows what the server said about a real failure", async () => { + h.snapshot.mockResolvedValue({ + type: "failed", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + message: "Installing Coolify failed (exit 1).", + log: "dpkg: error processing", + cancelled: false, + }); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-failed-log")).toBeTruthy(), + ); + expect( + screen.getByTestId("coolify-setup-failed-log").textContent, + ).toContain("dpkg: error processing"); + // And what went wrong, which the installer's output does not always say. + expect( + screen.getByTestId("coolify-setup-failed-message").textContent, + ).toContain("Installing Coolify failed"); + }); +}); + +describe("when it finishes", () => { + const DONE_RESULT = { + dashboardUrl: "https://203.0.113.5.sslip.io", + secure: true, + insecureReason: null, + adminEmail: "me@gmail.com", + adminPassword: "Abc123@xyz", + tokenStored: true, + // A token comes from a mint, and Dyad enables the API to reach one. + apiEnabled: true, + tokenUnavailableReason: null, + version: "4.3.2", + }; + + const doneState = (over: Record = {}) => ({ + type: "done" as const, + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + result: { ...DONE_RESULT, ...over }, + }); + + it("does not keep a token for an unencrypted address unless asked to", async () => { + // The token is held rather than written when the run ends, so continuing + // past the warning without a word leaves it unkept. + h.snapshot.mockResolvedValue( + doneState({ secure: false, insecureReason: "No certificate arrived." }), + ); + const user = userEvent.setup(); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-continue")).toBeTruthy(), + ); + await user.click(screen.getByTestId("coolify-setup-continue")); + + expect(h.acceptInsecureToken).not.toHaveBeenCalled(); + }); + + it("stays put when the token could not be kept", async () => { + // Dismissing here would put the screen away having agreed to something + // that was never stored, and say nothing about why. + h.snapshot.mockResolvedValue( + doneState({ secure: false, insecureReason: "No certificate arrived." }), + ); + h.acceptInsecureToken.mockRejectedValue(new Error("keychain locked")); + const user = userEvent.setup(); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-accept-insecure")).toBeTruthy(), + ); + await user.click(screen.getByTestId("coolify-setup-accept-insecure")); + await user.click(screen.getByTestId("coolify-setup-continue")); + + expect(h.showError).toHaveBeenCalled(); + expect(h.dismiss).not.toHaveBeenCalled(); + }); + + it("says how to make a token by hand when the offered one is not kept", async () => { + // Leaving the box unticked drops the token, and this is the only place + // that says how to make one instead. + h.snapshot.mockResolvedValue( + doneState({ secure: false, insecureReason: "No certificate arrived." }), + ); + renderPanel(); + + const panel = await waitFor(() => + screen.getByTestId("coolify-setup-manual-token"), + ); + expect(panel.textContent).toContain("Security → API Tokens"); + // A token Dyad made and will drop is not one it could not make. Saying + // the latter here would contradict the offer to keep it, directly above. + expect(panel.textContent).toContain("Unless you tick the box above"); + expect(panel.textContent).not.toContain("could not create"); + // Minting the token turned the API on, so this is not still to do. + expect(panel.textContent).not.toContain("enable the API"); + expect(screen.getByTestId("coolify-setup-done").textContent).toContain( + "It is not kept unless you say so", + ); + }); + + it("keeps it when the address is agreed to", async () => { + h.snapshot.mockResolvedValue( + doneState({ secure: false, insecureReason: "No certificate arrived." }), + ); + const user = userEvent.setup(); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-accept-insecure")).toBeTruthy(), + ); + await user.click(screen.getByTestId("coolify-setup-accept-insecure")); + await user.click(screen.getByTestId("coolify-setup-continue")); + + expect(h.acceptInsecureToken).toHaveBeenCalled(); + }); + + it("asks nothing when the address is encrypted", async () => { + // Nothing crosses the network in the clear, so there is no decision. + h.snapshot.mockResolvedValue(doneState()); + const user = userEvent.setup(); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-continue")).toBeTruthy(), + ); + expect(screen.queryByTestId("coolify-setup-accept-insecure")).toBeNull(); + await user.click(screen.getByTestId("coolify-setup-continue")); + + expect(h.acceptInsecureToken).not.toHaveBeenCalled(); + }); + + it("shows the details, since this is the moment they are needed", async () => { + h.snapshot.mockResolvedValue(doneState({ tokenStored: false })); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-done")).toBeTruthy(), + ); + expect(screen.getByTestId("coolify-setup-password").textContent).toBe( + "Abc123@xyz", + ); + }); + + it("says nothing about encryption when the server got a certificate", async () => { + // Dyad asks for one and usually gets it, so a standing warning would be + // noise — and noise is what makes a real warning easy to miss. + h.snapshot.mockResolvedValue(doneState({ tokenStored: false })); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-done")).toBeTruthy(), + ); + expect(screen.queryByTestId("coolify-setup-insecure")).toBeNull(); + }); + + it("warns when it had to settle for plain HTTP", async () => { + h.snapshot.mockResolvedValue( + doneState({ secure: false, insecureReason: "No certificate arrived." }), + ); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-insecure")).toBeTruthy(), + ); + }); + + it("says what is left to do when only the token step failed", async () => { + h.snapshot.mockResolvedValue( + doneState({ + tokenStored: false, + apiEnabled: false, + tokenUnavailableReason: "too old", + }), + ); + renderPanel(); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-manual-token")).toBeTruthy(), + ); + const panel = screen.getByTestId("coolify-setup-manual-token"); + expect(panel.textContent).toContain("too old"); + // Nothing was switched on, so switching it on is still to do. + expect(panel.textContent).toContain("enable the API"); + }); + + it("points at the password it is asking to be copied", async () => { + // The one path where this screen holds the only copy: the install stood + // and the write did not. Sending the user the wrong way past it is how + // that copy gets lost. + h.snapshot.mockResolvedValue( + doneState({ + tokenStored: false, + apiEnabled: true, + tokenUnavailableReason: + "Dyad could not save these details on this computer. Copy the " + + "password above before leaving this screen.", + }), + ); + renderPanel(); + + const done = await waitFor(() => screen.getByTestId("coolify-setup-done")); + const text = done.textContent ?? ""; + expect(text).toContain("Copy the password above"); + // Above means above: the card carrying it is rendered over this panel. + expect(text.indexOf("Abc123@xyz")).toBeGreaterThan(-1); + expect(text.indexOf("Abc123@xyz")).toBeLessThan( + text.indexOf("Copy the password above"), + ); + }); + + it("does not ask for the API step when the mint was what failed", async () => { + // Dyad turns the API on and then mints, so an account with no team, or a + // link that drops, leaves the API on and no token. Saying to go and + // enable it sends the user after something already done. + h.snapshot.mockResolvedValue( + doneState({ + tokenStored: false, + apiEnabled: true, + tokenUnavailableReason: "This Coolify account has no team yet.", + }), + ); + renderPanel(); + + const panel = await waitFor(() => + screen.getByTestId("coolify-setup-manual-token"), + ); + expect(panel.textContent).toContain("has no team yet"); + expect(panel.textContent).toContain("Security → API Tokens"); + expect(panel.textContent).not.toContain("enable the API"); + }); + + it("puts the screen away and refreshes when the user moves on", async () => { + // The address and token are already stored, but every window is still + // holding the answer from before they were. + h.snapshot.mockResolvedValue(doneState()); + const onUseExisting = vi.fn(); + const user = userEvent.setup(); + const { invalidate } = renderPanel(onUseExisting); + + await waitFor(() => + expect(screen.getByTestId("coolify-setup-continue")).toBeTruthy(), + ); + await user.click(screen.getByTestId("coolify-setup-continue")); + + await waitFor(() => expect(h.dismiss).toHaveBeenCalled()); + expect(invalidate).toHaveBeenCalledWith({ + queryKey: queryKeys.coolify.all, + }); + // Refreshed before the screen is put away: the other order hands the + // panel back to a connector that still believes there is no token, and + // the empty install form flashes up. + expect(invalidate.mock.invocationCallOrder[0]).toBeLessThan( + h.dismiss.mock.invocationCallOrder[0], + ); + expect(onUseExisting).toHaveBeenCalledWith("https://203.0.113.5.sslip.io"); + // And before the screen is cleared: dismissing first puts the machine + // back to idle while the panel above still believes there is nothing to + // enter, so the empty install form appears in between. + expect(onUseExisting.mock.invocationCallOrder[0]).toBeLessThan( + h.dismiss.mock.invocationCallOrder[0], + ); + }); +}); diff --git a/src/components/CoolifyServerSetup.tsx b/src/components/CoolifyServerSetup.tsx new file mode 100644 index 0000000000..ef953e974b --- /dev/null +++ b/src/components/CoolifyServerSetup.tsx @@ -0,0 +1,635 @@ +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, Copy, Check, ServerCog } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ipc } from "@/ipc/types"; +import { SETUP_MACHINE_REPORTED } from "@/ipc/types/coolify_setup"; +import type { + SetupPreflight, + SetupResult, + SetupSnapshot, + SetupStep, +} from "@/ipc/types"; +import { showError } from "@/lib/toast"; +import { queryKeys } from "@/lib/queryKeys"; +import { useCoolifySetupSnapshot } from "@/hooks/useCoolifySetupSnapshot"; +import { adminEmailRefusal } from "@/shared/coolify_admin_email"; +import { isPlausibleInstanceDomain } from "@/shared/coolify_domain"; +import { selectCoolifySetupCapabilities } from "@/coolify_setup/capabilities"; + +/** + * Setting up a server that has nothing on it yet. + * + * The order on screen is the order the work has to happen in, and the first + * step is the only manual one: nothing can reach the server until Dyad's key is + * on it. Everything after that is Dyad's job, and the panel's remaining work is + * to make a multi-minute install look like progress rather than a hang. + */ + +const STEP_LABELS: Record = { + connecting: "Connecting to the server", + "checking-server": "Checking the server", + installing: "Installing Coolify", + "waiting-for-dashboard": "Waiting for Coolify to start", + "verifying-account": "Checking the admin account", + securing: "Setting up HTTPS", + "creating-token": "Setting up API access", + done: "Done", +}; + +function CopyButton({ value, label }: { value: string; label: string }) { + const [copied, setCopied] = useState(false); + const resetTimer = useRef>(undefined); + useEffect(() => () => clearTimeout(resetTimer.current), []); + return ( + + ); +} + +export function CoolifyServerSetup({ + onUseExisting, + heldServerUrl, + children, +}: { + /** + * Leaves the installer for the screen that connects a Coolify that exists. + * + * Given the address when there is one, because after an install that could + * not mint a token the next screen asks for the address of the server just + * built — and leaving it blank means typing from memory. + */ + onUseExisting: (instanceUrl?: string) => void; + /** + * The server Dyad already holds an account for, if it holds one. + * + * Installing is refused outright while it does — Dyad has the only copy of + * that password — so without this the button is live for a press that can + * only come back as an error. + */ + heldServerUrl?: string | null; + /** Sits under the form. Not under the run or the result, which have their + own next step and nothing to add to. */ + children?: ReactNode; +}) { + const queryClient = useQueryClient(); + const hostId = useId(); + const emailId = useId(); + const domainId = useId(); + const [host, setHost] = useState(""); + const [adminEmail, setAdminEmail] = useState(""); + const [customDomain, setCustomDomain] = useState(""); + // Unticked every time the screen appears: it is about the address this run + // ended on, not a preference that outlives it. + const [acceptedInsecureToken, setAcceptedInsecureToken] = useState(false); + + // Kept with the address it was asked about. The answer arrives after a + // round trip, and by then the address in the field may be a different + // machine — whose Install button this verdict would then disable. + const [inspection, setInspection] = useState<{ + host: string; + checks: SetupPreflight; + } | null>(null); + const inspectionForHost = + inspection && inspection.host === host.trim() ? inspection.checks : null; + const logRef = useRef(null); + + const serverKey = useQuery({ + queryKey: queryKeys.coolify.serverKey, + queryFn: () => ipc.coolifySetup.getServerKey(), + }); + const publicKey = serverKey.data?.publicKey ?? null; + + const snapshot = useCoolifySetupSnapshot(); + const setup: SetupSnapshot = snapshot.data ?? { type: "idle" }; + // What the machine allows, asked once and answered the same way the + // transition would. What the form allows — a usable address, a key that + // could be read — stays below with the fields it is about. + const can = selectCoolifySetupCapabilities(setup); + + useEffect(() => { + logRef.current?.scrollTo({ top: logRef.current.scrollHeight }); + }, [setup]); + + const inspect = useMutation({ + mutationFn: async () => { + const asked = host.trim(); + // Cleared before asking, so a check that fails leaves no verdict behind. + // The screen's premise is that Dyad installs onto the machine that just + // answered, and a pass from a previous answer contradicts it. + setInspection(null); + const checks = await ipc.coolifySetup.inspect({ + host: asked, + username: "root", + }); + setInspection({ host: asked, checks }); + return checks; + }, + onError: (error) => showError(error), + }); + + const run = useMutation({ + mutationFn: () => + ipc.coolifySetup.run({ + host: host.trim(), + username: "root", + adminEmail, + customDomain: customDomain.trim() || undefined, + }), + // What became of the run is read from the snapshot, which every window + // gets. Nothing is done with the answer here: this window may not be the + // one still watching by the time it arrives. Only a refusal to start — + // one setup at a time — belongs to the caller. + onError: (error) => { + // Anything the machine took on is on screen already — a failure with + // the installer's own words under it, or a cancel that correctly says + // nothing went wrong — and it says so on the error. Everything else is + // shown, because an error nobody reports is a button that does nothing: + // a refusal that never started, and whatever the IPC layer turns down + // before the handler is reached. + const machineReported = + (error as { code?: string }).code === SETUP_MACHINE_REPORTED; + if (machineReported) return; + showError(error); + }, + }); + + /** Puts the finished screen away and lets the panel behind catch up. */ + const leaveResult = async ( + instanceUrl?: string, + { acceptToken = false }: { acceptToken?: boolean } = {}, + ) => { + // Before the queries are refreshed, so the panel behind sees a token that + // has been agreed to rather than one on its way to being. + if (acceptToken) { + // Not swallowed. Dismissing over a failure here would put the screen + // away having agreed to something that was never stored, and the panel + // behind would read as unconnected with nothing said about why. + try { + await ipc.coolifySetup.acceptInsecureToken(); + } catch (error) { + showError(error); + return; + } + } + // Refreshed before the screen is put away. Dismissing first hands the + // panel back to a connector that still believes there is no token, so the + // empty install form flashes up before the right screen arrives. + // + // All of it, including the setup key, unlike the narrower predicate in + // useCoolifySetupSnapshot — deliberately, not by oversight. There the + // snapshot has just been pushed and re-reading it is how a finished run + // gets put back on a step it has left; here the screen is going away and + // the connector behind it needs everything current. Keep them apart. + await queryClient.invalidateQueries({ queryKey: queryKeys.coolify.all }); + // Told where to go before the screen is cleared. Dismissing first puts the + // machine back to idle while the panel above still believes there is + // nothing to enter, so the empty install form appears in between. + onUseExisting(instanceUrl); + await ipc.coolifySetup.dismiss().catch(showError); + }; + + const emailRefusal = adminEmail ? adminEmailRefusal(adminEmail) : null; + const emailLooksUsable = !emailRefusal; + const domainLooksUsable = + !customDomain.trim() || isPlausibleInstanceDomain(customDomain); + // --- Finished --- + if (setup.type === "done") { + const result = setup.result; + return ( +
+
+ + Coolify is installed +
+ {/* Dyad keeps these, so this is a copy rather than the only sight of + them. Put here anyway: this is the moment they are needed. */} +
+

+ Save these now, or find them again under the server list. +

+
+ Address +
+ {result.dashboardUrl} + +
+
+
+ Email +
+ {result.adminEmail} + +
+
+
+ Password +
+ + {result.adminPassword} + + +
+
+
+ {/* Only when it is true. Dyad asks for a certificate and usually gets + one, so a standing warning would be noise — and a warning nobody + sees when it matters is worse than one that appears only then. The + token here carries root abilities and travels on every deploy, not + once at setup. */} + {!result.secure && ( +
+

This server is not encrypted

+

+ {result.insecureReason} Dyad will still work, but its access token + crosses your network unencrypted every time it deploys. Adding a + domain that points at this server fixes it. +

+ {/* A decision rather than a notice, and only where there is one to + make: a token was created, and keeping it is what puts it on + the network. Unticked to start, so continuing without reading + this leaves Dyad unconnected rather than connected over a + address nobody agreed to. */} + {result.tokenStored && ( + + )} +
+ )} + {result.tokenStored && ( +

+ {result.secure + ? "Dyad created its own API token, so you can pick a server and project next." + : "Dyad created an API token for this server. It is not kept unless you say so above, because this address is not encrypted."} +

+ )} + {(!result.tokenStored || + (!result.secure && !acceptedInsecureToken)) && ( + // The install stands; only the last step did not. Saying so plainly + // beats implying the whole thing failed. A token Dyad made but will + // not keep is not a token it failed to make, so the two say so + // differently. +
+

One step left, in Coolify

+

+ {result.tokenStored + ? "Unless you tick the box above, Dyad forgets the token it made." + : (result.tokenUnavailableReason ?? + "Dyad could not create an API token automatically.")}{" "} + Open {result.dashboardUrl}, sign in with the details above,{" "} + {/* Dyad turns the API on before it mints, so this stays done + even when the mint is what failed. */} + {!result.apiEnabled && + "enable the API under Settings → Advanced, then "} + create a token under Security → API Tokens and paste it in on the + next screen. +

+
+ )} + +
+ ); + } + + // --- Running --- + if (setup.type === "running") { + return ( +
+
+ + {STEP_LABELS[setup.step]} +
+

+ Installing takes a couple of minutes. Leaving this screen does not + stop it. +

+ {setup.log && ( +
+ {setup.log} +
+ )} + +
+ ); + } + + // --- Setting up --- + return ( +
+

+ Dyad allows you to self-host an instance of Coolify to deploy your apps. + To install it you need a Linux server with root access and about 2GB of + memory. Easiest if you have not created the server yet, since the key + below can go in at that point. +

+ + {/* First because nothing else can happen until it is done. */} +
+ + {/* The provider's web form comes first because it is the path that + needs no terminal: most hosts take an SSH key when the server is + created, so this whole step can happen in a browser. Leading with + authorized_keys made the easy route look like the footnote. */} +

+ Easiest when creating the server: most hosts — DigitalOcean, Hetzner + and others — have an SSH keys field on the create + page. Paste this in there and the server will trust Dyad from the + moment it starts. +

+

+ For a server that already exists, add it as a new line in{" "} + /root/.ssh/authorized_keys on the server. +

+
+ + {publicKey ?? + (serverKey.isError ? "Could not read the key" : "Generating…")} + + {publicKey ? ( + + ) : ( + serverKey.isError && ( + + ) + )} +
+
+ +
+ + { + setHost(e.target.value); + setInspection(null); + }} + /> +
+ +
+ + setAdminEmail(e.target.value)} + /> + {/* Checked while typing, because neither reason is cheap to find out + later: a domain Coolify will not take costs the whole install, and + an address Dyad cannot put in a shell word costs a run that + connects, looks the server over, and then fails. */} + {emailRefusal && ( +

+ {emailRefusal} +

+ )} +

+ This is the account you would sign in to Coolify with. +

+
+ +
+ + {/* Dyad can get a certificate without this, using a free service that + turns an address into a name. Someone with their own domain is + better off using it: it is theirs, and that free service shares one + certificate allowance between everyone who uses it. */} + setCustomDomain(e.target.value)} + /> + {!domainLooksUsable && ( +

+ Use just the domain, with no port or path — for example + coolify.yourdomain.com. +

+ )} +

+ Point it at this server first. Leave blank and Dyad will set up HTTPS + using the server's address. +

+
+ + {/* Kept after a failure, not only during the run. The failure message + points at the installer's own output, and replacing the log with the + form the moment it fails leaves nothing to point at. */} + {/* One block, so Dismiss sits beside the message rather than inside the + log — a connection or preflight refusal carries no output, and would + otherwise have nothing to clear it. */} + {setup.type === "failed" && !setup.cancelled && ( +
+
+

+ {setup.message} +

+ +
+ {setup.log && ( +
+ {setup.log} +
+ )} +
+ )} + + {inspectionForHost && ( +
+ {inspectionForHost.hostFingerprint && ( + // Shown so someone who cares can compare it against their + // provider's console before Dyad sends anything to the machine. +
+ Server fingerprint + + {inspectionForHost.hostFingerprint} + +
+ )} + {inspectionForHost.memoryMb !== null && ( +
+ Memory + {inspectionForHost.memoryMb} MB +
+ )} + {!inspectionForHost.ready && ( +

+ {inspectionForHost.reason} +

+ )} +
+ )} + + {snapshot.isError && ( +

+ Could not read what Dyad is doing with servers right now.{" "} + +

+ )} + +
+ + +
+ {heldServerUrl && ( +

+ Dyad is holding the admin password for {heldServerUrl}, and it has the + only copy. Sign out of Coolify to set up another — that shows the + password one last time before forgetting it. +

+ )} + {!inspectionForHost && host.trim() && ( +

+ Check the server first. Dyad shows you its fingerprint, and installs + only onto the machine that answered. +

+ )} + + {children} +
+ ); +} diff --git a/src/components/CoolifySignOutDialog.test.tsx b/src/components/CoolifySignOutDialog.test.tsx new file mode 100644 index 0000000000..c521affd75 --- /dev/null +++ b/src/components/CoolifySignOutDialog.test.tsx @@ -0,0 +1,237 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/toast", () => ({ showError: vi.fn() })); + +const h = vi.hoisted(() => ({ revealCredentials: vi.fn() })); +vi.mock("@/ipc/types", () => ({ + ipc: { coolifySetup: { revealCredentials: h.revealCredentials } }, +})); + +const { queryKeys } = await import("@/lib/queryKeys"); +const { CoolifySignOutDialog: Dialog } = await import("./CoolifySignOutDialog"); + +const FULL = { + instance: { + url: "https://203.0.113.5.sslip.io", + apiToken: "1|abcdefghijklmnop", + }, + server: { + url: "https://203.0.113.5.sslip.io", + email: "me@gmail.com", + password: "Abc123@xyzAbc123@xyz", + }, +}; + +const onConfirm = vi.fn(); +const onOpenChange = vi.fn(); + +function Harness({ open }: { open: boolean }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + + + + ); +} + +function open(props: { open?: boolean } = {}) { + return render(); +} + +async function openAndSettle() { + const result = open(); + await waitFor(() => + expect(screen.getByTestId("coolify-sign-out-dialog")).toBeTruthy(), + ); + return result; +} + +function signOutButton() { + return screen.getByRole("button", { name: "Sign out" }) as HTMLButtonElement; +} + +beforeEach(() => { + vi.clearAllMocks(); + h.revealCredentials.mockResolvedValue(FULL); +}); + +describe("acknowledging the loss", () => { + it("will not sign out until the box is ticked", async () => { + // The whole point of the dialog: the password below is about to go and + // Dyad has the only copy, so confirming has to be a separate act. + await openAndSettle(); + + expect(signOutButton().disabled).toBe(true); + }); + + it("signs out once it is", async () => { + const user = userEvent.setup(); + await openAndSettle(); + + await user.click(screen.getByTestId("coolify-sign-out-acknowledge")); + await user.click(signOutButton()); + + expect(onConfirm).toHaveBeenCalled(); + }); + + it("starts unticked again the next time it opens", async () => { + // Otherwise a tick from an earlier sign-out arms this one, and the last + // look at the password is skipped. + const user = userEvent.setup(); + const { rerender } = await openAndSettle(); + await user.click(screen.getByTestId("coolify-sign-out-acknowledge")); + await waitFor(() => expect(signOutButton().disabled).toBe(false)); + + rerender(); + rerender(); + + await waitFor(() => expect(signOutButton().disabled).toBe(true)); + }); +}); + +describe("nothing to look at yet", () => { + it("will not sign out while it is still finding out", async () => { + // Ticking a box that says the details above have been saved, over a panel + // that has not shown any, is not the acknowledgement this asks for. + h.revealCredentials.mockReturnValue(new Promise(() => {})); + const user = userEvent.setup(); + open(); + + await waitFor(() => + expect(screen.getByTestId("coolify-sign-out-dialog")).toBeTruthy(), + ); + await user.click(screen.getByTestId("coolify-sign-out-acknowledge")); + + expect(signOutButton().disabled).toBe(true); + }); + + it("says so when it cannot read what it has stored", async () => { + // Signing out still forgets them, so staying silent would destroy + // credentials the user was never shown. + h.revealCredentials.mockRejectedValue(new Error("keychain locked")); + open(); + + await waitFor(() => + expect(screen.getByTestId("coolify-sign-out-unreadable")).toBeTruthy(), + ); + // Said once. The panel below reports the read; this only adds what it + // means for signing out, so both saying it reads as a stutter. + expect( + screen.queryAllByText(/could not read what it has stored/i), + ).toHaveLength(1); + // And said after it. "Anyway" contrasts with a failure, so meeting it + // first leaves the user contrasting with nothing. + const cause = screen.getByText(/could not read what it has stored/i); + const addendum = screen.getByTestId("coolify-sign-out-unreadable"); + expect( + cause.compareDocumentPosition(addendum) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("keeps the details on screen when only a later read failed", async () => { + // The panel still has them, so it still shows them. Saying a read failed + // over a panel full of credentials leaves a sentence hanging off nothing, + // and this is the moment the user is asked to confirm they saved them. + const user = userEvent.setup(); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + render( + + + , + ); + await waitFor(() => + expect(screen.getByTestId("coolify-field-password")).toBeTruthy(), + ); + + h.revealCredentials.mockRejectedValue(new Error("keychain locked")); + await client.refetchQueries({ queryKey: queryKeys.coolify.credentials }); + await waitFor(() => expect(h.revealCredentials).toHaveBeenCalledTimes(2)); + + expect(screen.getByTestId("coolify-field-password")).toBeTruthy(); + expect(screen.queryByTestId("coolify-sign-out-unreadable")).toBeNull(); + // And the acknowledgement still means what it says. + await user.click(screen.getByTestId("coolify-sign-out-acknowledge")); + expect(signOutButton().disabled).toBe(false); + }); + + it("says it is looking only once", async () => { + // The panel below says it now. Saying it here too put the same sentence + // on screen twice, which is what the read-failure line was moved for. + h.revealCredentials.mockReturnValue(new Promise(() => {})); + open(); + + expect( + await screen.findAllByText(/Looking up what Dyad has stored/), + ).toHaveLength(1); + }); + + it("says when it holds a password it cannot read", async () => { + // The panel cannot show a row for a value it does not have, so a missing + // password would otherwise read as there never having been one. + h.revealCredentials.mockResolvedValue({ + ...FULL, + server: { ...FULL.server, password: null }, + }); + open(); + + const addendum = await waitFor(() => + screen.getByTestId("coolify-sign-out-locked-password"), + ); + // Said once. The panel below states what Dyad is holding; this only adds + // what signing out does to it, so both saying it reads as a stutter. + expect(screen.queryAllByText(/holding an admin password/i)).toHaveLength(1); + // And said after it, for the same reason the read failure is. + const cause = screen.getByTestId("coolify-credentials-locked-password"); + expect( + cause.compareDocumentPosition(addendum) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); +}); + +describe("the last look", () => { + it("shows what is about to be forgotten", async () => { + await openAndSettle(); + + await waitFor(() => + expect(screen.getByTestId("coolify-credentials")).toBeTruthy(), + ); + expect(screen.getByTestId("coolify-field-address")).toBeTruthy(); + expect(screen.getByTestId("coolify-field-password")).toBeTruthy(); + }); + + it("says the password cannot be got back when there is one", async () => { + // Coolify can mint another token; it cannot tell anyone this password. + await openAndSettle(); + + await waitFor(() => + expect( + screen.getByText(/only thing holding it/, { exact: false }), + ).toBeTruthy(), + ); + }); + + it("does not say it for an instance Dyad did not set up", async () => { + // Connected by pasting a token, so nothing here was invented by Dyad and + // a warning about losing it forever would be untrue. + h.revealCredentials.mockResolvedValue({ ...FULL, server: null }); + await openAndSettle(); + + await waitFor(() => expect(h.revealCredentials).toHaveBeenCalled()); + expect(screen.queryByText(/only thing holding it/)).toBeNull(); + }); + + it("asks for nothing while it is closed", async () => { + open({ open: false }); + + expect(h.revealCredentials).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/CoolifySignOutDialog.tsx b/src/components/CoolifySignOutDialog.tsx new file mode 100644 index 0000000000..e30f98de3b --- /dev/null +++ b/src/components/CoolifySignOutDialog.tsx @@ -0,0 +1,130 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Checkbox } from "@/components/ui/checkbox"; +import { CoolifyCredentials } from "@/components/CoolifyCredentials"; +import { ipc } from "@/ipc/types"; +import { queryKeys } from "@/lib/queryKeys"; + +/** + * The last look at credentials Dyad is about to forget. + * + * Signing out clears the instance outright rather than keeping a copy around + * for an instance nothing is connected to. That is only fair if the user gets + * to take the details with them first, so they are on screen here with their + * copy buttons, behind an acknowledgement rather than a plain confirm. + */ +export function CoolifySignOutDialog({ + open, + onOpenChange, + onConfirm, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) { + const [acknowledged, setAcknowledged] = useState(false); + + // Ticked once is not ticked forever: every open starts from unticked, so + // the acknowledgement is about this sign-out. Keyed to `open` rather than + // done on the way out, because closing is not always the user's doing. + useEffect(() => { + if (!open) setAcknowledged(false); + }, [open]); + + // Shares a key with the fields below, so opening this asks once. Only read + // here to decide whether the password gets a warning of its own. + const { + data: credentials, + isPending, + isError, + } = useQuery({ + queryKey: queryKeys.coolify.credentials, + queryFn: () => ipc.coolifySetup.revealCredentials(), + gcTime: 0, + enabled: open, + }); + // A tick over an empty panel is not the acknowledgement this asks for, so + // nothing can be confirmed until the read has answered. `enabled` leaves the + // query pending while the dialog is closed, which reads the same here. + const answered = !isPending || isError; + // What the panel below reports: a read that failed with nothing already in + // hand. A failed refetch over details it can still show is not one, and the + // line below would then hang off nothing. + const readFailed = isError && !credentials; + // Held but unreadable. The panel below names it; this adds only what + // signing out does to it, so the two read as one thought rather than as + // the same sentence twice. + const passwordIsLocked = + credentials?.server !== null && + credentials?.server !== undefined && + credentials.server.password === null; + + return ( + + + + Sign out of Coolify? + + Dyad will forget the details below. Your server keeps running and + your apps keep their settings. + {credentials?.server?.password + ? " Dyad made this password up and is the only thing holding it — Coolify cannot show it to you again." + : ""} + + + + + + {passwordIsLocked && ( +
+ It goes when you sign out, unread. +
+ )} + + {readFailed && ( +
+ Signing out forgets it anyway. +
+ )} + + + + + Cancel + {/* Closes as it fires — it is a Close underneath — so there is no + pending state to show here. The button that opened this stays on + screen and carries that. */} + + Sign out + + +
+
+ ); +} diff --git a/src/coolify_setup/admin_credentials.test.ts b/src/coolify_setup/admin_credentials.test.ts new file mode 100644 index 0000000000..1332e8c390 --- /dev/null +++ b/src/coolify_setup/admin_credentials.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + buildAdminCredentials, + generateAdminPassword, + isShellSafe, +} from "./admin_credentials"; + +describe("generateAdminPassword", () => { + it("meets every rule Coolify checks", () => { + // Its validator wants length, mixed case, a digit and a symbol. Failing any + // of them leaves the install finished with no account on it. + for (let i = 0; i < 200; i++) { + const pw = generateAdminPassword(); + expect(pw.length).toBe(24); + expect(pw).toMatch(/[A-Z]/); + expect(pw).toMatch(/[a-z]/); + expect(pw).toMatch(/[0-9]/); + expect(pw).toMatch(/[@%^*_\-+=]/); + } + }); + + it("never produces a character that would break out of its quoting", () => { + // The value crosses a shell assignment and lands in a .env file. A quote or + // a backslash ends the quoting; # starts a comment and would truncate it. + for (let i = 0; i < 200; i++) { + expect(isShellSafe(generateAdminPassword())).toBe(true); + } + }); + + it("does not always put the required characters first", () => { + // They are appended before shuffling, so a missing shuffle would leave the + // first four positions perfectly predictable. + const firstFour = new Set( + Array.from({ length: 60 }, () => generateAdminPassword().slice(0, 4)), + ); + expect(firstFour.size).toBeGreaterThan(1); + }); +}); + +describe("buildAdminCredentials", () => { + it("keeps the address it was given rather than inventing one", () => { + const creds = buildAdminCredentials(" someone@gmail.com "); + expect(creds.email).toBe("someone@gmail.com"); + expect(creds.username).toBe("dyad-admin"); + expect(isShellSafe(creds.password)).toBe(true); + }); +}); diff --git a/src/coolify_setup/admin_credentials.ts b/src/coolify_setup/admin_credentials.ts new file mode 100644 index 0000000000..b33f6bf211 --- /dev/null +++ b/src/coolify_setup/admin_credentials.ts @@ -0,0 +1,77 @@ +import { randomInt } from "crypto"; + +/** + * The admin account Coolify seeds itself with during installation. + * + * Coolify creates its first user from environment variables the installer + * writes, which is what lets Dyad set a server up without the user ever opening + * the dashboard. The values only take effect while no admin exists, so this + * cannot take over an instance somebody is already using. + */ + +/** + * Excludes quote, backslash, backtick and $ so the value survives a shell, and + * # and ! because these land in a .env file, where # starts a comment and would + * silently truncate the password. + */ +const UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ"; +const LOWER = "abcdefghijkmnopqrstuvwxyz"; +const DIGITS = "23456789"; +const SYMBOLS = "@%^*_-+="; + +function pick(alphabet: string): string { + return alphabet[randomInt(alphabet.length)]; +} + +/** + * A password Coolify will accept. + * + * Its rule is at least eight characters with an upper case letter, a lower case + * letter, a digit and a symbol, and it additionally rejects passwords found in + * known breaches. A generated 24-character value clears both, though the breach + * check is a network call made by the server, so seeding needs the server to + * have outbound access. + */ +export function generateAdminPassword(length = 24): string { + const required = [pick(UPPER), pick(LOWER), pick(DIGITS), pick(SYMBOLS)]; + const all = UPPER + LOWER + DIGITS + SYMBOLS; + const rest = Array.from( + { length: Math.max(0, length - required.length) }, + () => pick(all), + ); + const chars = [...required, ...rest]; + // Fisher-Yates, so the four required characters are not always at the front. + for (let i = chars.length - 1; i > 0; i--) { + const j = randomInt(i + 1); + [chars[i], chars[j]] = [chars[j], chars[i]]; + } + return chars.join(""); +} + +export interface AdminCredentials { + username: string; + email: string; + password: string; +} + +/** + * Whether a value can be carried to the server without being escaped. + * + * These are written into a shell assignment and then into a .env file. Rather + * than escape them for each, values containing a character that would end the + * quoting are rejected outright — getting that subtly wrong runs arbitrary text + * as a command on somebody's server. + */ +export function isShellSafe(value: string): boolean { + return !/['"\\`$\n\r#!]/.test(value); +} + +export function buildAdminCredentials(email: string): AdminCredentials { + return { + username: "dyad-admin", + // Asked for rather than invented, because the domain has to resolve and + // because this is the address the user signs in with afterwards. + email: email.trim(), + password: generateAdminPassword(), + }; +} diff --git a/src/coolify_setup/api_token.test.ts b/src/coolify_setup/api_token.test.ts new file mode 100644 index 0000000000..52d0395983 --- /dev/null +++ b/src/coolify_setup/api_token.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it, vi } from "vitest"; +import { COOLIFY_SCOPES_PHP_ARRAY } from "@/shared/coolify_scopes"; +import { + compareVersions, + enableApi, + mintApiToken, + readCoolifyVersion, + supportsAutomaticToken, + tryAutomaticAccess, +} from "./api_token"; +import { DyadErrorKind } from "@/errors/dyad_error"; +import { SshError } from "@/ipc/utils/ssh_client"; +import type { SshSession } from "@/ipc/utils/ssh_client"; + +/** Wraps a value the way a real tinker transcript carries it. */ +function transcript(output: string): string { + return [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "> __DYAD_OUT_START__", + output, + "__DYAD_OUT_END__", + ].join("\n"); +} + +/** A token of the shape Sanctum actually returns. */ +const REAL_TOKEN = "1|EcaUxT43T5fgdLJmnYj0702tEUC6viy5jEhO3Ujk2298db95"; + +function fakeSession(replies: string[]): SshSession & { + scripts: string[]; + commands: string[]; +} { + const scripts: string[] = []; + const commands: string[] = []; + let call = 0; + return { + scripts, + commands, + run: vi.fn(async (command: string, options?: { input?: string }) => { + commands.push(command); + scripts.push(options?.input ?? ""); + const reply = replies[Math.min(call, replies.length - 1)]; + call += 1; + return { code: 0, stdout: transcript(reply), stderr: "" }; + }) as unknown as SshSession["run"], + end: vi.fn(), + }; +} + +describe("compareVersions", () => { + it("orders by number, not by text", () => { + // Compared as strings, "4.10.0" sorts below "4.9.0" and the automated path + // would quietly switch itself off on newer instances. + expect(compareVersions("4.10.0", "4.9.0")).toBe(1); + expect(compareVersions("4.3.2", "4.3.2")).toBe(0); + expect(compareVersions("4.3", "4.3.0")).toBe(0); + expect(compareVersions("3.9.9", "4.0.0")).toBe(-1); + }); +}); + +describe("supportsAutomaticToken", () => { + it.each([ + ["4.3.2", true], + ["4.0.0", true], + ["4.10.0", true], + ["3.9.0", false], + [null, false], + ])("reads %s as %s", (version, expected) => { + expect(supportsAutomaticToken(version)).toBe(expected); + }); +}); + +describe("readCoolifyVersion", () => { + it("asks the instance, because the installer picks the version", async () => { + const session = fakeSession(["4.3.2"]); + expect(await readCoolifyVersion(session)).toBe("4.3.2"); + }); + + it("reads the version past a notice on either side of it", async () => { + // The first of the five readers, and the one the rest are behind: a + // notice here costs the whole automated-token path on a server that + // answered correctly. After it, the notice would have been glued onto + // the version and stored as it. + expect( + await readCoolifyVersion(fakeSession(["PHP Deprecated: x\n4.3.2"])), + ).toBe("4.3.2"); + expect( + await readCoolifyVersion(fakeSession(["4.3.2\nPHP Deprecated: x"])), + ).toBe("4.3.2"); + }); + + it("says it could not read the version rather than that it is old", async () => { + // The installer always fetches the newest Coolify, so "too old" is the + // least likely thing to be true here — and the key this reads is + // Coolify's own, free to be renamed, which is the ordinary way to get an + // answer that is not a version. Saying the version is unsupported sends + // the user looking for a problem with a server they just installed. + const session = fakeSession(["Command not found"]); + await expect(readCoolifyVersion(session)).rejects.toThrow( + /could not read which version/, + ); + }); + + it("does not call a lost link an unreadable version", async () => { + // Answering null here sends the caller down the path that tells the user + // their freshly installed Coolify is too old to drive, for a question + // that never reached it. + const session = { + run: vi.fn(async () => { + throw new SshError( + // The connection stopped answering, which is the case this tells + // apart from a bound Dyad set on one command. + "timeout", + "the connection stopped answering", + DyadErrorKind.External, + ); + }), + end: vi.fn(), + } as unknown as SshSession; + + await expect(readCoolifyVersion(session)).rejects.toBeInstanceOf(SshError); + }); + + it("says a slow answer was slow rather than calling the version old", async () => { + // The instance is reachable and simply did not answer in time, which on + // a small server right after an install is ordinary. Answered as null, + // the user is told the Coolify they just installed is too old to drive. + const session = { + run: vi.fn(async () => { + throw new SshError( + "command-timeout", + "timed out", + DyadErrorKind.External, + ); + }), + end: vi.fn(), + } as unknown as SshSession; + + await expect(readCoolifyVersion(session)).rejects.toThrow( + /did not answer in time/, + ); + }); + + it("does not call a container still starting an unreadable version", async () => { + // No markers back means the script never ran — a container still coming + // up. Its own message says to wait, which is the useful thing to say. + const session = { + run: vi.fn(async () => ({ code: 0, stdout: "", stderr: "" })), + end: vi.fn(), + } as unknown as SshSession; + + await expect(readCoolifyVersion(session)).rejects.toThrow( + /may still be starting/, + ); + }); +}); + +describe("enableApi", () => { + it("confirms the setting took rather than assuming the write worked", async () => { + const session = fakeSession(["enabled"]); + await expect(enableApi(session)).resolves.toBeUndefined(); + expect(session.scripts[0]).toContain("is_api_enabled = true"); + // Read back from the database rather than off the property just set: + // tinker carries on after a statement throws, so a save that never + // happened would otherwise still answer "enabled". + expect(session.scripts[0]).toContain("$ok = $s->save()"); + expect(session.scripts[0]).toContain( + "$ok && \\App\\Models\\InstanceSettings::get()->is_api_enabled", + ); + }); + + it("reads the answer past a notice Coolify printed", async () => { + // Three of the four readers required the whole region to be the answer, + // so one deprecation notice would have reported a working server broken. + const session = fakeSession(["PHP Deprecated: something\nenabled"]); + await expect(enableApi(session)).resolves.toBeUndefined(); + }); + + it("still names a missing team past a notice", async () => { + // Read as the whole answer, a notice beside the sentinel turned "no team + // yet" into "no usable token" — the same words a real failure gets, and + // none of the reason. + const session = fakeSession(["PHP Deprecated: x\nno-team"]); + await expect(mintApiToken(session, "me@gmail.com")).rejects.toThrow( + /no team yet/, + ); + }); + + it("fails when the setting did not take", async () => { + const session = fakeSession(["still-disabled"]); + await expect(enableApi(session)).rejects.toMatchObject({ + kind: "external", + }); + }); +}); + +describe("mintApiToken", () => { + it("seeds a team into the session before creating the token", async () => { + // Not defensive: Coolify's createToken override stamps the row with + // session('currentTeam')->id, and tinker has no session, so the insert + // fails on a not-null team_id without this. + const session = fakeSession([REAL_TOKEN]); + await mintApiToken(session, "admin@gmail.com"); + expect(session.scripts[0]).toContain("session(['currentTeam' => $team])"); + }); + + it("asks for the scopes Dyad tells users to tick", async () => { + // Narrower tokens hide a server's private key id, which the deploy path + // reads to tell a stale key from one it simply cannot see. + const session = fakeSession([REAL_TOKEN]); + await mintApiToken(session, "admin@gmail.com"); + // The same list the panel tells a user to tick and the 403 message names, + // so a token Dyad mints and one made by hand behave alike. Not root, which + // Coolify treats as a bypass of the ability check rather than a scope. + expect(session.scripts[0]).toContain(COOLIFY_SCOPES_PHP_ARRAY); + expect(session.scripts[0]).not.toContain("root"); + }); + + it("keeps the address out of the script", async () => { + const session = fakeSession([REAL_TOKEN]); + await mintApiToken(session, "admin@gmail.com"); + expect(session.scripts[0]).not.toContain("admin@gmail.com"); + expect(session.commands[0]).toContain( + "-e DYAD_ADMIN_EMAIL='admin@gmail.com'", + ); + }); + + it("uses no early return, which tinker cannot parse", async () => { + // A `return` at top level is a parse error, and a script that does not + // parse prints nothing at all — so the branch it guarded never reports. + const session = fakeSession([REAL_TOKEN]); + await mintApiToken(session, "admin@gmail.com"); + expect(session.scripts[0]).not.toMatch(/\breturn\s*;/); + }); + + it("says so when the account does not exist", async () => { + const session = fakeSession(["no-user"]); + await expect( + mintApiToken(session, "nobody@gmail.com"), + ).rejects.toMatchObject({ + kind: "precondition", + }); + }); + + it("says so when the account has no team", async () => { + const session = fakeSession(["no-team"]); + await expect( + mintApiToken(session, "admin@gmail.com"), + ).rejects.toMatchObject({ + kind: "precondition", + }); + }); + + it("refuses anything that is not a token", async () => { + // A warning line stored as a credential fails much later, somewhere that + // cannot explain what went wrong. + const session = fakeSession(["PHP Warning: something"]); + await expect( + mintApiToken(session, "admin@gmail.com"), + ).rejects.toMatchObject({ + kind: "external", + }); + }); + + it("refuses a token name that could break out of its quoting", async () => { + const session = fakeSession([REAL_TOKEN]); + await expect( + mintApiToken(session, "admin@gmail.com", { + tokenName: "a', ['root'], null); //", + }), + ).rejects.toMatchObject({ kind: "internal" }); + }); +}); + +describe("tryAutomaticAccess", () => { + it("returns a token when the instance can be driven", async () => { + const session = fakeSession(["4.3.2", "enabled", REAL_TOKEN]); + const access = await tryAutomaticAccess(session, "admin@gmail.com"); + expect(access?.token).toBe(REAL_TOKEN); + expect(access?.version).toBe("4.3.2"); + }); + + it("declines quietly on an instance it does not know", async () => { + // Not a failure: an instance Dyad did not install is the ordinary case, + // and the caller asks for a token by hand instead. + const session = fakeSession(["3.1.0"]); + expect(await tryAutomaticAccess(session, "admin@gmail.com")).toBeNull(); + }); + + it("does not try to enable the API on an instance it declined", async () => { + const session = fakeSession(["3.1.0"]); + await tryAutomaticAccess(session, "admin@gmail.com"); + expect(session.scripts.some((s) => s.includes("is_api_enabled"))).toBe( + false, + ); + }); +}); diff --git a/src/coolify_setup/api_token.ts b/src/coolify_setup/api_token.ts new file mode 100644 index 0000000000..0aebf4c979 --- /dev/null +++ b/src/coolify_setup/api_token.ts @@ -0,0 +1,274 @@ +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { COOLIFY_SCOPES_PHP_ARRAY } from "@/shared/coolify_scopes"; +import { SshError } from "@/ipc/utils/ssh_client"; +import type { SshSession } from "@/ipc/utils/ssh_client"; +import { answerLine, runTinker } from "./tinker"; + +/** + * How long one of these questions may take. + * + * Each is a tinker one-liner against a Coolify that is already up, so the + * honest answer arrives in seconds. Unbounded, a wedged docker leaves + * "Setting up API access" on screen forever with nothing behind it. + */ +const TINKER_TIMEOUT_MS = 30_000; + +/** + * Turning Coolify's API on and creating a token for it, without the dashboard. + * + * Coolify ships with its API off and offers no way to change that or to create + * a token except by hand in the browser. So both are done by driving Laravel + * and Sanctum directly, which is not an interface anybody promised to keep. + * Every workaround below is marked, and each says what would replace it. + * + * All of it is best-effort. When any part of it does not work the caller falls + * back to asking the user for a token, which is the path Dyad has always had. + */ + +/** + * The last version this was checked against. + * + * Not a maximum. The path is attempted on anything at or above the version it + * was written for and simply reports failure otherwise — refusing outright + * would turn a working instance into a manual one for no reason, and the + * fallback already covers being wrong. + */ +export const VERIFIED_AGAINST = "4.3.2"; + +/** Coolify's API was off by default when this was written. */ +const MINIMUM_SUPPORTED = "4.0.0"; + +/** + * Compares dotted versions numerically. + * + * String comparison puts 4.10.0 before 4.9.0, which would silently disable the + * automated path on newer instances. + */ +export function compareVersions(a: string, b: string): number { + const parse = (v: string) => + v + .trim() + .split(".") + .map((part) => Number.parseInt(part, 10) || 0); + const left = parse(a); + const right = parse(b); + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const diff = (left[i] ?? 0) - (right[i] ?? 0); + if (diff !== 0) return diff > 0 ? 1 : -1; + } + return 0; +} + +/** + * Reads the version of the Coolify that was just installed. + * + * Read rather than chosen: the installer resolves "latest" when it runs, so + * there is no version to pin at install time and the only way to know what + * landed is to ask it afterwards. + * + * WORKAROUND: the version endpoint needs the API, and whether the API can be + * turned on is what this answers, so it reads the config value directly. + * + * TODO: replace if a fresh Coolify gains a way to state its version before its + * API is reachable. + */ +export async function readCoolifyVersion( + session: SshSession, + { signal }: { signal?: AbortSignal } = {}, +): Promise { + try { + const output = await runTinker( + session, + `echo config('constants.coolify.version');`, + { signal, timeoutMs: TINKER_TIMEOUT_MS }, + ); + const said = answerLine(output, (line) => /^\d+\.\d+/.test(line)); + if (said) return said; + // It answered, and what it said was not a version. Null would send the + // caller down the path that reports a Coolify too old to drive — and the + // installer always fetches the newest one, so that is the least likely + // thing to be true. The key this reads is Coolify's own and free to move, + // which is the ordinary way to arrive here. + throw new DyadError( + "Dyad could not read which version of Coolify this is, so it could not " + + "set up an API token by itself. The server is installed — open it " + + "and make a token there.", + DyadErrorKind.External, + ); + } catch (error) { + // A cancelled setup is the user stopping, not an instance that cannot be + // driven. Swallowing it here would report a version problem for something + // they did on purpose, and carry on setting the server up. + if ((error as { kind?: string }).kind === "user_cancelled") throw error; + // Every way of not getting an answer says so as itself. Reported as a + // version this instance does not have, the user is sent looking for a + // problem with a server that is minutes old — so the only thing that + // returns from here is a version it actually read. + if (error instanceof SshError && error.failure === "command-timeout") { + // Reachable and simply slow, which on a small server right after an + // install is ordinary. Worth saying as itself: the version is not the + // problem, and there is nothing to fix by finding a newer Coolify. + throw new DyadError( + "Coolify did not answer in time when Dyad asked which version it " + + "is. It may still be starting up — open it and connect with a " + + "token once it does.", + DyadErrorKind.External, + ); + } + throw error; + } +} + +export function supportsAutomaticToken(version: string | null): boolean { + if (!version) return false; + return compareVersions(version, MINIMUM_SUPPORTED) >= 0; +} + +/** + * Turns the API on. + * + * WORKAROUND: Coolify has a migration named disable_api_by_default and no + * setting, command or endpoint to reverse it, so this writes the model field + * the dashboard's own toggle writes. + * + * TODO: replace with whatever Coolify offers if it ever gains a way to enable + * the API at install time — an installer environment variable would be the + * natural shape, and would make this function unnecessary rather than smaller. + */ +export async function enableApi( + session: SshSession, + { signal }: { signal?: AbortSignal } = {}, +): Promise { + const output = await runTinker( + session, + [ + `$s = \\App\\Models\\InstanceSettings::get();`, + `$s->is_api_enabled = true;`, + `$ok = $s->save();`, + // Read back rather than read off what was just assigned. Tinker keeps + // going after a statement throws, so echoing the property would say + // "enabled" for a save that never happened — and the screen would then + // leave out the one step the user still had to do by hand. + `echo $ok && \\App\\Models\\InstanceSettings::get()->is_api_enabled ? 'enabled' : 'still-disabled';`, + ].join("\n"), + { signal, timeoutMs: TINKER_TIMEOUT_MS }, + ); + // One line of the answer, not the whole of it: Coolify prints its own + // notices between the markers. + if (!answerLine(output, (line) => line === "enabled")) { + throw new DyadError( + "Could not turn Coolify's API on automatically.", + DyadErrorKind.External, + ); + } +} + +/** + * Creates an API token for Dyad. + * + * WORKAROUND, in two parts. + * + * There is no artisan command that mints a token, so this calls Sanctum's + * createToken directly. And Coolify overrides createToken to stamp the row with + * `session('currentTeam')->id`; tinker has no session, so without seeding one + * the insert fails on a not-null constraint against team_id. The session line + * is doing real work, not defensive coding. + * + * TODO: replace both halves if Coolify gains a token-minting command or an + * endpoint that does not need a browser session. + */ + +export async function mintApiToken( + session: SshSession, + adminEmail: string, + { + signal, + tokenName = "dyad", + }: { signal?: AbortSignal; tokenName?: string } = {}, +): Promise { + if (!/^[A-Za-z0-9 _-]{1,40}$/.test(tokenName)) { + throw new DyadError( + `Unsafe token name: ${tokenName}`, + DyadErrorKind.Internal, + ); + } + const output = await runTinker( + session, + // One statement per line and no early returns: tinker evaluates what it is + // fed as top-level code, where `return` is a parse error — and a script + // that does not parse produces no output at all rather than the branch it + // was meant to take. + [ + `$u = \\App\\Models\\User::where('email', getenv('DYAD_ADMIN_EMAIL'))->first();`, + `$team = $u ? $u->teams()->first() : null;`, + // The session line is doing real work: Coolify's createToken override + // reads the team from a session tinker does not otherwise have, and the + // insert fails on team_id without it. + `if ($team) { session(['currentTeam' => $team]); }`, + `echo !$u ? 'no-user' : (!$team ? 'no-team' : $u->createToken('${tokenName}', ${COOLIFY_SCOPES_PHP_ARRAY}, null)->plainTextToken);`, + ].join("\n"), + { + env: { DYAD_ADMIN_EMAIL: adminEmail }, + signal, + timeoutMs: TINKER_TIMEOUT_MS, + }, + ); + + const token = output; + if (answerLine(token, (line) => line === "no-user")) { + throw new DyadError( + "Coolify has no account for this address, so no token could be created.", + DyadErrorKind.Precondition, + ); + } + if (answerLine(token, (line) => line === "no-team")) { + throw new DyadError( + "Coolify's admin account has no team yet, so no token could be created.", + DyadErrorKind.Precondition, + ); + } + // Sanctum's plain text token is `|<40+ characters>`. Read as one line + // among any others, so a notice beside it does not lose the token — and + // checked for shape, so a notice is never stored as one. + const minted = answerLine(token, (line) => + /^\d+\|[A-Za-z0-9]{40,}$/.test(line), + ); + if (!minted) { + throw new DyadError( + "Coolify did not return a usable API token.", + DyadErrorKind.External, + ); + } + return minted; +} + +export interface AutomaticAccess { + token: string; + version: string | null; +} + +/** + * Enables the API and mints a token, or reports that it could not. + * + * Returns null rather than throwing when the instance is one this cannot drive, + * because that is not a failure — it is the ordinary case of an instance Dyad + * did not install, and the caller asks for a token by hand instead. + */ +export async function tryAutomaticAccess( + session: SshSession, + adminEmail: string, + { + signal, + onApiEnabled, + }: { signal?: AbortSignal; onApiEnabled?: () => void } = {}, +): Promise { + const version = await readCoolifyVersion(session, { signal }); + if (!supportsAutomaticToken(version)) return null; + await enableApi(session, { signal }); + // Said here rather than inferred from the token below, because minting is + // its own step and can fail on its own — an account with no team, a link + // that drops — long after this one has taken effect on the server. + onApiEnabled?.(); + const token = await mintApiToken(session, adminEmail, { signal }); + return { token, version }; +} diff --git a/src/coolify_setup/capabilities.test.ts b/src/coolify_setup/capabilities.test.ts new file mode 100644 index 0000000000..91ef778d2b --- /dev/null +++ b/src/coolify_setup/capabilities.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { assertCapabilityTransitionConsistency } from "@/state_machines/testing"; +import { selectCoolifySetupCapabilities } from "./capabilities"; +import { coolifySetupTransition } from "./transition"; +import { IDLE, type CoolifySetupState } from "./state"; +import type { SetupResult } from "@/ipc/types/coolify_setup"; + +const REF = { + kind: "coolify-setup" as const, + entityKey: "203.0.113.5", + operationId: "op-1", +}; + +const OTHER = { + kind: "coolify-setup" as const, + entityKey: "198.51.100.9", + operationId: "op-9", +}; + +const running = (stopping = false): CoolifySetupState => ({ + type: "running", + host: "203.0.113.5", + invocationRef: REF, + step: "installing", + log: "", + stopping, +}); + +const RESULT: SetupResult = { + dashboardUrl: "https://203.0.113.5.sslip.io", + secure: true, + insecureReason: null, + adminEmail: "me@gmail.com", + adminPassword: "Abc123@xyz", + tokenStored: true, + apiEnabled: true, + tokenUnavailableReason: null, + version: "4.3.2", +}; + +const done = (): CoolifySetupState => ({ + type: "done", + host: "203.0.113.5", + invocationRef: REF, + result: RESULT, +}); + +const failed = (): CoolifySetupState => ({ + type: "failed", + host: "203.0.113.5", + invocationRef: REF, + message: "boom", + log: "output", + cancelled: false, +}); + +describe("what the panel may offer", () => { + it("offers a start when nothing is going on", () => { + expect(selectCoolifySetupCapabilities(IDLE)).toMatchObject({ + canStart: true, + canCancel: false, + }); + }); + + it("refuses a second start, which is what the machine would say anyway", () => { + // Offering it means the answer to pressing it is an error rather than an + // install, and the transition already refuses with "already-running". + expect(selectCoolifySetupCapabilities(running()).canStart).toBe(false); + }); + + it("offers a cancel only while there is something to stop", () => { + expect(selectCoolifySetupCapabilities(running()).canCancel).toBe(true); + expect(selectCoolifySetupCapabilities(IDLE).canCancel).toBe(false); + }); + + it("stops offering a cancel once one has been asked for", () => { + expect(selectCoolifySetupCapabilities(running(true)).canCancel).toBe(false); + }); +}); + +describe("against what the machine would actually do", () => { + it("keeps every enabled control consistent with the transition", () => { + // Two statements of one rule, in two files. Offering a control the + // machine refuses turns a button into an error message; refusing one it + // would take strands the user on a screen with nothing to press. The + // gate that guards installing again reads this selector too, so drift + // here is not only cosmetic. + expect(() => + assertCapabilityTransitionConsistency({ + states: [IDLE, running(), running(true), done(), failed()], + selectCapabilities: selectCoolifySetupCapabilities, + transition: coolifySetupTransition, + cases: { + canStart: { + representativeEvents: () => ({ + valid: [ + { + type: "start-requested" as const, + invocationRef: OTHER, + target: { + host: "198.51.100.9", + username: "root", + adminEmail: "me@gmail.com", + }, + }, + ], + }), + disabledReason: "already-running", + }, + canCancel: { + representativeEvents: () => ({ + valid: [{ type: "cancel-requested" as const }], + }), + }, + }, + }), + ).not.toThrow(); + }); +}); diff --git a/src/coolify_setup/capabilities.ts b/src/coolify_setup/capabilities.ts new file mode 100644 index 0000000000..a7cc03f5cd --- /dev/null +++ b/src/coolify_setup/capabilities.ts @@ -0,0 +1,32 @@ +import type { CoolifySetupState } from "./state"; + +export interface CoolifySetupCapabilities { + /** + * Whether an install may be started. + * + * False while one is going, because the machine refuses a second one — + * offering the button anyway means the answer to pressing it is an error + * message rather than an install. + */ + readonly canStart: boolean; + /** Whether there is something to stop. */ + readonly canCancel: boolean; +} + +/** + * Pure domain policy for the controls on the setup panel. + * + * Only what the state decides. Whether the address looks like an address, or + * whether the key could be read, are questions about the form in front of the + * user and stay with it — this answers the questions the machine owns, and + * answers them the same way the transition does. + */ +export function selectCoolifySetupCapabilities( + state: CoolifySetupState, +): CoolifySetupCapabilities { + const isRunning = state.type === "running"; + return { + canStart: !isRunning, + canCancel: isRunning && !state.stopping, + }; +} diff --git a/src/coolify_setup/controller.test.ts b/src/coolify_setup/controller.test.ts new file mode 100644 index 0000000000..133ed1b4a9 --- /dev/null +++ b/src/coolify_setup/controller.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, it, vi } from "vitest"; +import { CoolifySetupController } from "./controller"; +import type { CoolifySetupState } from "./state"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import type { SetupResult, SetupStep, SetupTarget } from "@/ipc/types"; + +const TARGET: SetupTarget = { + host: "203.0.113.5", + username: "root", + adminEmail: "me@gmail.com", +}; + +const RESULT: SetupResult = { + dashboardUrl: "https://203.0.113.5.sslip.io", + secure: true, + insecureReason: null, + adminEmail: "me@gmail.com", + adminPassword: "Abc123@xyz", + tokenStored: true, + apiEnabled: true, + tokenUnavailableReason: null, + version: "4.3.2", +}; + +/** Deterministic, because a machine keyed on identity must be reproducible. */ +function ids() { + let n = 0; + return { next: () => `op-${++n}` }; +} + +function harness( + execute: CoolifySetupController extends never + ? never + : ConstructorParameters[0]["execute"], +) { + const states: CoolifySetupState[] = []; + const controller = new CoolifySetupController({ + execute, + ids: ids(), + onChanged: (state) => states.push(state), + }); + return { controller, states }; +} + +/** A run the test finishes by hand. */ +function deferred() { + let resolve!: (value: SetupResult) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("starting", () => { + it("reports what is happening, so a panel can be a view of it", async () => { + const gate = deferred(); + let report!: (step: SetupStep, output?: string) => void; + const { controller } = harness(async (_t, hooks) => { + report = hooks.onProgress; + return gate.promise; + }); + + controller.start(TARGET); + expect(controller.getState()).toMatchObject({ + type: "running", + host: "203.0.113.5", + step: "connecting", + }); + + report("installing", "3/6 Pulling..."); + expect(controller.getState()).toMatchObject({ + step: "installing", + log: "3/6 Pulling...", + }); + + gate.resolve(RESULT); + await controller.getState(); + }); + + it("refuses a second setup while one is running", async () => { + const gate = deferred(); + const { controller } = harness(async () => gate.promise); + controller.start(TARGET); + + expect(() => controller.start({ ...TARGET, host: "198.51.100.9" })).toThrow( + /already being set up/, + ); + gate.resolve(RESULT); + }); + + it("lets another start once the first has finished", async () => { + const first = deferred(); + let calls = 0; + const { controller } = harness(async () => { + calls += 1; + return calls === 1 ? first.promise : RESULT; + }); + + const run = controller.start(TARGET); + first.resolve(RESULT); + await run.result; + controller.dismiss(); + + expect(() => controller.start(TARGET)).not.toThrow(); + }); +}); + +describe("finishing", () => { + it("keeps the result where a panel can find it after the fact", async () => { + const { controller } = harness(async () => RESULT); + const run = controller.start(TARGET); + await run.result; + + expect(controller.getState()).toMatchObject({ type: "done" }); + }); + + it("hands the caller what the work produced", async () => { + const { controller } = harness(async () => RESULT); + expect(await controller.start(TARGET).result).toEqual(RESULT); + }); + + it("keeps a failure and the output that explains it", async () => { + const { controller } = harness(async (_t, hooks) => { + hooks.onProgress("installing", "3/6 Pulling..."); + throw new DyadError("exit 1", DyadErrorKind.External); + }); + + await expect(controller.start(TARGET).result).rejects.toThrow("exit 1"); + expect(controller.getState()).toMatchObject({ + type: "failed", + message: "exit 1", + log: "3/6 Pulling...", + cancelled: false, + }); + }); + + it("marks a cancellation as one rather than as a fault", async () => { + const { controller } = harness(async (_t, hooks) => { + await new Promise((resolve) => + hooks.signal.addEventListener("abort", () => resolve()), + ); + throw new DyadError("Cancelled.", DyadErrorKind.UserCancelled); + }); + + const run = controller.start(TARGET); + controller.cancel(); + await expect(run.result).rejects.toThrow("Cancelled."); + expect(controller.getState()).toMatchObject({ + type: "failed", + cancelled: true, + }); + }); +}); + +describe("cancelling", () => { + it("aborts the work rather than only saying so", async () => { + let aborted = false; + const gate = deferred(); + const { controller } = harness(async (_t, hooks) => { + hooks.signal.addEventListener("abort", () => { + aborted = true; + }); + return gate.promise; + }); + + controller.start(TARGET); + controller.cancel(); + + expect(aborted).toBe(true); + expect(controller.getState()).toMatchObject({ stopping: true }); + gate.resolve(RESULT); + }); + + it("carries out a command from a transition that stays put", async () => { + // A second cancel does not change the state, and setState answers "no + // change" the same way it answers "disposed" — so the abort it asks for + // has to be run on the strength of the transition, not the state change. + const abort = vi.spyOn(AbortController.prototype, "abort"); + const gate = deferred(); + const { controller } = harness(async () => gate.promise); + + controller.start(TARGET); + const before = abort.mock.calls.length; + controller.cancel(); + controller.cancel(); + + expect(abort.mock.calls.length - before).toBe(2); + gate.resolve(RESULT); + abort.mockRestore(); + }); + + it("is quiet when there is nothing to stop", () => { + const { controller } = harness(async () => RESULT); + expect(() => controller.cancel()).not.toThrow(); + expect(controller.getState()).toEqual({ type: "idle" }); + }); +}); + +describe("answers from a run nobody is watching any more", () => { + it("does not put a superseded run's result back on screen", async () => { + // The reason the state is here and not in the panel: this run finishes + // after the user has moved on, and its answer must not revive anything. + const first = deferred(); + let calls = 0; + const { controller } = harness(async () => { + calls += 1; + return calls === 1 ? first.promise : RESULT; + }); + + const stale = controller.start(TARGET); + controller.cancel(); + first.reject(new DyadError("Cancelled.", DyadErrorKind.UserCancelled)); + await expect(stale.result).rejects.toThrow(); + controller.dismiss(); + + // A second run of the same server, then the first one's answer arrives. + const second = controller.start(TARGET); + await second.result; + expect(controller.getState()).toMatchObject({ type: "done" }); + }); +}); + +describe("what a run could not put back", () => { + it("reaches the state a panel reads, not just the error", async () => { + // The whole hop: an error carrying it, the dispatch that reads it off, + // and the transition that has to carry it onto the state. Asserting on + // either end alone leaves the middle free to drop it, which is what + // happened. + const { controller } = harness(async () => { + throw Object.assign( + new DyadError("Cancelled.", DyadErrorKind.UserCancelled), + { warning: "Coolify may still be configured for x.sslip.io." }, + ); + }); + + await controller.start(TARGET).result.catch(() => {}); + + expect(controller.getState()).toMatchObject({ + type: "failed", + cancelled: true, + warning: "Coolify may still be configured for x.sslip.io.", + }); + }); + + it("says nothing when a run had nothing to put back", async () => { + const { controller } = harness(async () => { + throw new DyadError("boom", DyadErrorKind.External); + }); + + await controller.start(TARGET).result.catch(() => {}); + + expect( + (controller.getState() as { warning?: string }).warning, + ).toBeUndefined(); + }); +}); + +describe("telling anyone who is listening", () => { + it("reports each change once, so windows can follow along", async () => { + const { controller, states } = harness(async (_t, hooks) => { + hooks.onProgress("installing"); + return RESULT; + }); + + await controller.start(TARGET).result; + + expect(states.map((s) => s.type)).toEqual(["running", "running", "done"]); + }); + + it("says nothing when the answer is the same as last time", async () => { + // Starting already puts it at "connecting". Reporting that again is not a + // change, and telling every window about it is a render for nothing. + const { controller, states } = harness(async (_t, hooks) => { + hooks.onProgress("connecting"); + return RESULT; + }); + + await controller.start(TARGET).result; + + expect(states.map((s) => s.type)).toEqual(["running", "done"]); + }); + + it("says nothing when it ignored the event", () => { + const { controller, states } = harness(async () => RESULT); + controller.cancel(); + controller.dismiss(); + expect(states).toHaveLength(0); + }); +}); + +describe("an executor that throws before it starts", () => { + it("does not leave the machine running with nothing running", async () => { + // Reading the server key throws where it stands, not as a rejection, and + // handlers attached to a promise that was never made never run — so the + // machine would stay running and refuse every later setup. + const { controller } = harness((() => { + throw new Error("key file is corrupt"); + }) as never); + + const run = controller.start(TARGET); + await expect(run.result).rejects.toThrow("key file is corrupt"); + + expect(controller.getState()).toMatchObject({ type: "failed" }); + controller.dismiss(); + expect(() => controller.start(TARGET)).not.toThrow(); + }); +}); + +describe("after it is disposed", () => { + it("starts nothing new, rather than working where nobody can see it", async () => { + // A disposed store keeps nothing and tells nobody. Launching anyway would + // put an install on somebody's server that no window could show, cancel, + // or hear the end of. + let launched = 0; + const { controller } = harness(async () => { + launched += 1; + return RESULT; + }); + + controller.dispose(); + expect(() => controller.start(TARGET)).toThrow(); + expect(launched).toBe(0); + }); + + it("stops telling anyone, so a late answer cannot reach a closed window", async () => { + const gate = deferred(); + const { controller, states } = harness(async () => gate.promise); + controller.start(TARGET); + const before = states.length; + + controller.dispose(); + gate.resolve(RESULT); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(states).toHaveLength(before); + }); +}); + +describe("not taking the process down", () => { + it("survives a failure nobody awaited", async () => { + const rejection = vi.fn(); + process.once("unhandledRejection", rejection); + const { controller } = harness(async () => { + throw new Error("nobody is listening"); + }); + + controller.start(TARGET); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(rejection).not.toHaveBeenCalled(); + process.off("unhandledRejection", rejection); + }); +}); diff --git a/src/coolify_setup/controller.ts b/src/coolify_setup/controller.ts new file mode 100644 index 0000000000..3b6ab60cea --- /dev/null +++ b/src/coolify_setup/controller.ts @@ -0,0 +1,217 @@ +import log from "electron-log"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { createInvocationRef } from "@/state_machines/invocation_ref"; +import type { IdSource } from "@/state_machines/clock"; +import type { + SetupResult, + SetupStep, + SetupTarget, +} from "@/ipc/types/coolify_setup"; +import { + COOLIFY_SETUP_INVOCATION_KIND, + IDLE, + type CoolifySetupCommand, + type CoolifySetupEvent, + type CoolifySetupInvocationRef, + type CoolifySetupState, +} from "./state"; +import { coolifySetupTransition } from "./transition"; +import { SnapshotStore } from "@/state_machines/snapshot_store"; + +const logger = log.scope("coolify_setup_controller"); + +/** + * Runs the setup machine and performs what it asks for. + * + * The state lives here, in the main process, because the work does. The panel + * asks for a snapshot and renders it; it keeps nothing of its own, so leaving + * the screen — which the running screen invites — cannot lose the install. + * + * Every launch mints an invocation ref, and every event echoes it back. An + * answer from a run that was cancelled or superseded is ignored by the + * transition rather than guarded against here. + */ + +export interface SetupRun { + /** Resolves with what the flow produced, or rejects as the flow did. */ + readonly result: Promise; + readonly invocationRef: CoolifySetupInvocationRef; +} + +export interface CoolifySetupControllerOptions { + /** Does the work. Injected so the controller can be tested without SSH. */ + execute: ( + target: SetupTarget, + hooks: { + signal: AbortSignal; + onProgress: (step: SetupStep, output?: string) => void; + }, + ) => Promise; + ids: IdSource; + /** Told after every applied transition, so windows can be updated. */ + onChanged?: (state: CoolifySetupState) => void; +} + +export class CoolifySetupController { + /** + * The snapshot everything else reads. + * + * The shared store rather than a field and a callback: it already refuses + * to notify when the reference has not moved, which is the difference + * between `stay` and `change`, and it can be disposed so a late answer + * cannot reach windows after teardown. + */ + private readonly store = new SnapshotStore(IDLE); + private aborters = new Map(); + private disposed = false; + private runs = new Map>(); + + constructor(private readonly options: CoolifySetupControllerOptions) { + if (options.onChanged) { + this.store.subscribe(() => options.onChanged?.(this.store.getSnapshot())); + } + } + + getState(): CoolifySetupState { + return this.store.getSnapshot(); + } + + /** Stops telling anyone anything. Nothing in flight is cancelled by it. */ + dispose(): void { + this.disposed = true; + this.store.dispose(); + } + + /** + * Starts a setup, or refuses because one is already going. + * + * The refusal is the machine's decision rather than a check here, so the + * rule is tested where it is made. + */ + start(target: SetupTarget): SetupRun { + const invocationRef = createInvocationRef( + COOLIFY_SETUP_INVOCATION_KIND, + target.host.trim(), + this.options.ids, + ); + const before = this.store.getSnapshot(); + this.dispatch({ type: "start-requested", invocationRef, target }); + if (this.store.getSnapshot() === before) { + // Nothing launched: the machine refused. Named for the user rather than + // for the log, because this is what the panel shows. + throw new DyadError( + "A server is already being set up. Wait for it to finish, or cancel it.", + DyadErrorKind.Precondition, + ); + } + const result = this.runs.get(invocationRef.operationId); + if (!result) { + // Unreachable: a launch command is emitted with the state change. + throw new DyadError( + "The setup did not start.", + DyadErrorKind.Precondition, + ); + } + return { result, invocationRef }; + } + + /** Asks a running setup to stop. Quiet when there is nothing to stop. */ + cancel(): void { + this.dispatch({ type: "cancel-requested" }); + } + + /** The user has read the terminal screen; put the panel back to the form. */ + dismiss(): void { + this.dispatch({ type: "dismissed" }); + } + + private dispatch(event: CoolifySetupEvent): void { + const result = coolifySetupTransition(this.store.getSnapshot(), event); + if (result.kind === "ignored") { + logger.info(`Ignored ${event.type}: ${result.reason}`); + return; + } + // Told first, then the work is started. A command that dispatches again — + // the flow reports its first step as soon as it is launched — then sends + // its own notification, so each one says something that changed rather + // than two saying the same thing. + // + // A disposed store keeps nothing and tells nobody, so running the + // commands anyway would start an install over SSH that no window could + // see, cancel, or hear the end of — while `start` reported it as running. + // A transition can ask for something without changing the state — a + // second cancel re-sends the abort — and setState answers false for that + // as well as for a disposed store. Only the disposed case is a reason to + // do nothing. + this.store.setState(result.state); + if (this.disposed) return; + for (const command of result.commands) this.run(command); + } + + private run(command: CoolifySetupCommand): void { + switch (command.type) { + case "launch": + this.launch(command.invocationRef, command.target); + return; + case "abort": + this.aborters.get(command.invocationRef.operationId)?.abort(); + return; + } + } + + private launch( + invocationRef: CoolifySetupInvocationRef, + target: SetupTarget, + ): void { + const controller = new AbortController(); + this.aborters.set(invocationRef.operationId, controller); + + // Started inside a try, because an executor can throw before it returns a + // promise — reading the server key does, on a key file it cannot parse. + // Handlers attached to a promise that was never made never run, which + // would leave the machine running with nothing running. + let started: Promise; + try { + started = this.options.execute(target, { + signal: controller.signal, + onProgress: (step, output) => + this.dispatch({ type: "progress", invocationRef, step, output }), + }); + } catch (error) { + started = Promise.reject(error); + } + + const work = started + .then((result) => { + this.dispatch({ type: "succeeded", invocationRef, result }); + return result; + }) + .catch((error: unknown) => { + const cancelled = + (error as { kind?: string }).kind === DyadErrorKind.UserCancelled; + this.dispatch({ + type: "failed", + invocationRef, + message: error instanceof Error ? error.message : String(error), + cancelled, + // Set by whatever could not put the server back as it found it. + warning: + error instanceof Error + ? (error as Error & { warning?: string }).warning + : undefined, + }); + throw error; + }) + .finally(() => { + this.aborters.delete(invocationRef.operationId); + this.runs.delete(invocationRef.operationId); + }); + + // Kept so a second caller can await the same work rather than starting + // its own, and so the handler that asked can answer its own invoke. + this.runs.set(invocationRef.operationId, work); + // Nothing else attaches to this promise, and an install that fails with + // no window listening must not take the process down. + work.catch(() => {}); + } +} diff --git a/src/coolify_setup/https_setup.test.ts b/src/coolify_setup/https_setup.test.ts new file mode 100644 index 0000000000..371986c272 --- /dev/null +++ b/src/coolify_setup/https_setup.test.ts @@ -0,0 +1,877 @@ +import { describe, expect, it, vi } from "vitest"; +import { + applyInstanceDomain, + plainUrlFor, + certificateDomainFor, + domainPointsAtServer, + hasTrustedCertificate, + tryEnableHttps, +} from "./https_setup"; +import type { SshSession } from "@/ipc/utils/ssh_client"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; + +function transcript(output: string): string { + return [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "> __DYAD_OUT_START__", + output, + "__DYAD_OUT_END__", + ].join("\n"); +} + +function fakeSession() { + const commands: string[] = []; + const scripts: string[] = []; + const session: SshSession = { + run: vi.fn(async (command: string, options?: { input?: string }) => { + commands.push(command); + scripts.push(options?.input ?? ""); + return { code: 0, stdout: transcript("applied"), stderr: "" }; + }) as unknown as SshSession["run"], + end: vi.fn(), + }; + return { session, commands, scripts }; +} + +describe("certificateDomainFor", () => { + it("spells a bare address as a name, since a certificate needs one", () => { + // Let's Encrypt will not issue for an IP address. sslip.io exists so an + // address can be written as a domain that resolves back to it. + expect(certificateDomainFor("203.0.113.5")).toBe("203.0.113.5.sslip.io"); + }); + + it("prefers a name the user already has", () => { + // Theirs, and it does not spend a shared certificate allowance. + expect(certificateDomainFor("203.0.113.5", "coolify.example.com")).toBe( + "coolify.example.com", + ); + }); + + it("takes a domain however the user pasted it", () => { + expect( + certificateDomainFor("203.0.113.5", "https://coolify.example.com/"), + ).toBe("coolify.example.com"); + }); + + it("uses a hostname as given rather than wrapping it", () => { + expect(certificateDomainFor("coolify.example.com")).toBe( + "coolify.example.com", + ); + }); + + it("declines an address no certificate authority could reach", () => { + // Validation happens over the public internet. A name pointing at a LAN + // or loopback address can never be given a certificate, and asking for + // one costs the whole poll — two minutes — before the same answer. + expect(certificateDomainFor("192.168.1.50")).toBeNull(); + expect(certificateDomainFor("127.0.0.1")).toBeNull(); + expect(certificateDomainFor("10.0.0.7")).toBeNull(); + }); + + it("declines a name only this machine or this network answers to", () => { + // Same position as a private address: nothing public can validate it. + expect(certificateDomainFor("localhost")).toBeNull(); + // mDNS, which is how a homelab box is usually reached on a LAN. + expect(certificateDomainFor("coolify.local")).toBeNull(); + }); + + it("still takes an ordinary hostname", () => { + expect(certificateDomainFor("coolify.example.com")).toBe( + "coolify.example.com", + ); + }); + + it("keeps a domain the user gave, whatever the address is", () => { + // Not because the certificate can be had — the caller turns this server + // down a step later for the address it resolves to. It decides which + // refusal the user reads: with the domain kept, the message names the + // address, which is the thing that is actually private. Nulled here, it + // would name the domain instead and send someone to check a name that + // was answering perfectly well. + expect(certificateDomainFor("192.168.1.50", "coolify.example.com")).toBe( + "coolify.example.com", + ); + }); + + it("declines an IPv6 address rather than guessing at a spelling", () => { + expect(certificateDomainFor("2606:4700::1")).toBeNull(); + }); +}); + +describe("plainUrlFor", () => { + it("brackets an IPv6 address, or the URL will not parse", async () => { + const url = plainUrlFor("2606:4700::1"); + expect(url).toBe("http://[2606:4700::1]:8000"); + expect(() => new URL(url)).not.toThrow(); + }); + + it("leaves an IPv4 address and a hostname alone", () => { + expect(plainUrlFor("203.0.113.5")).toBe("http://203.0.113.5:8000"); + expect(plainUrlFor("box.example.com")).toBe("http://box.example.com:8000"); + }); +}); + +describe("applyInstanceDomain", () => { + it("sets the domain and rebuilds the proxy, which is what asks", async () => { + // The setting alone changes nothing; the proxy rebuild is what requests + // the certificate. + const { session, scripts } = fakeSession(); + await applyInstanceDomain(session, "203.0.113.5.sslip.io"); + + expect(scripts[0]).toContain("$s->fqdn ="); + expect(scripts[0]).toContain("setupDynamicProxyConfiguration"); + }); + + it("keeps the domain out of the script", async () => { + const { session, scripts, commands } = fakeSession(); + await applyInstanceDomain(session, "coolify.example.com"); + + expect(scripts[0]).not.toContain("coolify.example.com"); + expect(commands[0]).toContain( + "-e DYAD_INSTANCE_DOMAIN='coolify.example.com'", + ); + }); + + it("clears the domain when given null", async () => { + const { session, scripts } = fakeSession(); + await applyInstanceDomain(session, null); + expect(scripts[0]).toContain("$s->fqdn = null;"); + }); + + it("refuses a domain that could break out of the script", async () => { + const { session } = fakeSession(); + await expect( + applyInstanceDomain(session, "example.com'; system('rm -rf /'); '"), + ).rejects.toMatchObject({ kind: "validation" }); + }); +}); + +describe("hasTrustedCertificate", () => { + it("is false when the certificate is not trusted", async () => { + // Node rejects an untrusted certificate rather than reporting it, so the + // request failing IS the answer. A self-signed one fails here, which is + // the point: it would fail in the user's browser too. + const fetchImpl = vi.fn(async () => { + throw new Error("unable to verify the first certificate"); + }) as unknown as typeof fetch; + expect( + await hasTrustedCertificate("https://example.com", { fetchImpl }), + ).toBe(false); + }); + + it("is true when the request completes", async () => { + const fetchImpl = vi.fn(async () => ({ + status: 302, + })) as unknown as typeof fetch; + expect( + await hasTrustedCertificate("https://example.com", { fetchImpl }), + ).toBe(true); + }); +}); + +describe("certificateDomainFor, for a domain given by hand", () => { + it("refuses a name nothing public can validate", async () => { + // Same reasoning the derived names get. Asking anyway spends the whole + // certificate wait — two minutes — on an answer that cannot arrive. + expect(certificateDomainFor("203.0.113.5", "coolify.local")).toBeNull(); + expect(certificateDomainFor("203.0.113.5", "localhost")).toBeNull(); + expect(certificateDomainFor("203.0.113.5", "192.168.1.10")).toBeNull(); + }); + + it("turns a bare address into the name the derived path would use", async () => { + // No authority certifies a bare address, so asking spends the whole wait + // on a refusal — while the same address has a spelling that is a name. + expect(certificateDomainFor("203.0.113.5", "203.0.113.5")).toBe( + "203.0.113.5.sslip.io", + ); + }); + + it("keeps a name that could be validated", async () => { + expect(certificateDomainFor("203.0.113.5", "coolify.example.com")).toBe( + "coolify.example.com", + ); + expect( + certificateDomainFor("203.0.113.5", "https://coolify.example.com/"), + ).toBe("coolify.example.com"); + }); +}); + +describe("domainPointsAtServer", () => { + const answers = + (addresses: string[], failed = false) => + async () => ({ addresses, failed }); + + it("says it points here when the domain resolves to the server", async () => { + expect( + await domainPointsAtServer("coolify.example.com", "203.0.113.5", { + resolve: answers(["203.0.113.5"]), + }), + ).toBe("points-here"); + }); + + it("is false when it still points at something else", async () => { + // The user's old website answers HTTPS with a valid certificate of its + // own, so the certificate check alone would call this a success. + expect( + await domainPointsAtServer("example.com", "203.0.113.5", { + resolve: answers(["198.51.100.9"]), + }), + ).toBe("points-elsewhere"); + }); + + it("says it does not know when the resolver could not be reached", async () => { + // Told apart from an answer, because the certificate poll that follows + // settles for any address serving a certificate it trusts — so a domain + // still pointing at the machine the user is moving off would pass it. + expect( + await domainPointsAtServer("example.com", "203.0.113.5", { + resolve: answers([], true), + }), + ).toBe("no-answer"); + }); + + it("says nothing is known when neither side resolved", async () => { + // The domain having no records is answered first, before ours is even + // looked at — so a server that does not resolve and a domain with none + // yet came back as agreement, having compared nothing at all. + const nothingAnywhere = async () => ({ addresses: [], failed: false }); + + expect( + await domainPointsAtServer("coolify.example.com", "box.internal", { + resolve: nothingAnywhere, + }), + ).toBe("server-unresolved"); + }); + + it("says it does not know when the records cannot be compared", async () => { + // An IPv4 server and a domain carrying only an AAAA record. There is no + // overlap to find by construction, so this is no more an answer about + // where the domain points than a resolver that never replied — and the + // certificate poll after it would take the old machine for proof. + expect( + await domainPointsAtServer("coolify.example.com", "203.0.113.5", { + resolve: answers(["2001:db8::9"]), + }), + ).toBe("different-families"); + }); + + it("does not object when the domain has no records yet", async () => { + // The resolver answered; the name simply has nothing yet. It may be + // minutes old, and the certificate wait is the real answer. + expect( + await domainPointsAtServer("example.com", "203.0.113.5", { + resolve: answers([]), + }), + ).toBe("points-here"); + }); + + it("compares a server known by a name against what the name resolves to", async () => { + // Both sides are names here, so both are resolved. Accepting any domain + // when the server is named would point Coolify — and the root token + // stored with it — at whatever that domain happens to serve. + const byName = async (target: string) => ({ + addresses: + target === "box.example.com" ? ["203.0.113.5"] : ["198.51.100.9"], + failed: false, + }); + + expect( + await domainPointsAtServer("coolify.example.com", "box.example.com", { + resolve: byName, + }), + ).toBe("points-elsewhere"); + }); + + it("accepts a domain that resolves to the same place as the named server", async () => { + expect( + await domainPointsAtServer("coolify.example.com", "box.example.com", { + resolve: answers(["203.0.113.5"]), + }), + ).toBe("points-here"); + }); + + it("says it does not know when the server's own name does not resolve", async () => { + // A name only this machine or this network answers to: connectSsh reaches + // it, plain DNS cannot see it, and there is nothing to hold the domain + // against. Accepting compared the user's domain with nothing at all — + // and the certificate poll afterwards takes any trusted answer for proof. + const nothingForTheServer = async (target: string) => ({ + addresses: target === "box.internal" ? [] : ["198.51.100.9"], + failed: target === "box.internal", + }); + + expect( + await domainPointsAtServer("coolify.example.com", "box.internal", { + resolve: nothingForTheServer, + }), + ).toBe("server-unresolved"); + }); +}); + +describe("tryEnableHttps", () => { + const FAST = { timeoutMs: 40, intervalMs: 5 }; + + it("asks DNS about the server once, not once per check", async () => { + // The gate below and the domain comparison want the same answer, and a + // resolver that is slow to say so is slow twice. + const asked: string[] = []; + const { session } = fakeSession(); + await tryEnableHttps(session, "box.example.com", { + ...FAST, + customDomain: "coolify.example.com", + resolve: async (name: string) => { + asked.push(name); + return { addresses: ["203.0.113.5"], failed: false }; + }, + check: async () => true, + }); + + expect(asked.filter((n) => n === "box.example.com")).toHaveLength(1); + }); + + it("will not take a custom domain on trust when DNS could not be checked", async () => { + // The certificate poll settles for any address answering with a trusted + // certificate, and resolves the name through the system rather than the + // resolver asked here. A domain still pointing at the machine the user is + // moving off would pass it, and its address would become the instance the + // API token is sent to on every deploy. + const { session } = fakeSession(); + const result = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "coolify.example.com", + resolve: async () => ({ addresses: [], failed: true }), + // Would say yes, which is the point: it is never asked. + check: async () => true, + }); + + expect(result.secure).toBe(false); + expect(result.instanceUrl).toBe("http://203.0.113.5:8000"); + expect(result.reason).toMatch(/could not look up where/i); + }); + + it("names the domain it refused rather than the address", async () => { + // The address was fine; it was the domain the user typed that could not + // be certified, and blaming the address sends them to check that. + const { session } = fakeSession(); + const result = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "coolify.local", + check: async () => true, + }); + + expect(result.secure).toBe(false); + expect(result.reason).toMatch(/^coolify\.local cannot be given/i); + }); + + it("says which of the two it could not settle", async () => { + // Two refusals with two different remedies. Telling someone whose domain + // resolved fine to go and check that it resolves sends them after the + // thing that was working. + const { session } = fakeSession(); + const noAnswer = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "coolify.example.com", + resolve: async () => ({ addresses: [], failed: true }), + check: async () => true, + }); + const crossFamily = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "coolify.example.com", + resolve: async () => ({ addresses: ["2001:db8::9"], failed: false }), + check: async () => true, + }); + + expect(noAnswer.secure).toBe(false); + expect(noAnswer.reason).toMatch(/could not look up where/i); + expect(crossFamily.secure).toBe(false); + expect(crossFamily.reason).toMatch(/different families/i); + expect(crossFamily.reason).not.toMatch(/could not look up/i); + }); + + it("refuses a custom domain when the server's name says nothing", async () => { + // The server answers to a name plain DNS cannot see. There is nothing to + // hold the domain against, and the poll below settles for any address + // serving a trusted certificate — so a domain pointing at the machine + // being moved off would be taken for proof. + const { session } = fakeSession(); + const result = await tryEnableHttps(session, "box.internal", { + ...FAST, + customDomain: "coolify.example.com", + resolve: async (name: string) => ({ + addresses: name === "box.internal" ? [] : ["198.51.100.9"], + failed: name === "box.internal", + }), + check: async () => true, + }); + + expect(result.secure).toBe(false); + expect(result.instanceUrl).toBe("http://box.internal:8000"); + // Names the server, which is what could not be looked up — not the + // domain, which may be perfectly correct. + expect(result.reason).toMatch(/could not look up an address for/i); + expect(result.reason).toContain("box.internal"); + }); + + it("does not say which side holds which family", async () => { + // The server is the IPv6 side here. A message naming the domain as the + // one with IPv6 records would be false, and its remedy — give the + // server's address in the same family — is what the user already did. + const { session } = fakeSession(); + const result = await tryEnableHttps(session, "box.example.com", { + ...FAST, + customDomain: "coolify.example.com", + resolve: async (name: string) => ({ + addresses: + name === "box.example.com" ? ["2001:db8::1"] : ["203.0.113.5"], + failed: false, + }), + check: async () => true, + }); + + expect(result.secure).toBe(false); + expect(result.reason).not.toMatch(/only IPv6/i); + expect(result.reason).toMatch(/different families/i); + }); + + it("does not ask DNS about a name it derived from this server", async () => { + // The sslip.io spelling of the host resolves to the host by construction, + // so checking it can only fail for reasons that have nothing to do with + // where it points — and the advice would be to fix a name that is right. + const { session } = fakeSession(); + const result = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "203.0.113.5", + resolve: async () => ({ addresses: [], failed: true }), + check: async () => true, + }); + + expect(result.secure).toBe(true); + expect(result.instanceUrl).toBe("https://203.0.113.5.sslip.io"); + }); + + it("still checks an address typed there that is not this server", async () => { + // Derived the same way, but from somewhere else — so it resolves + // somewhere else by construction, which is exactly what to object to. + const { session } = fakeSession(); + const result = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "198.51.100.9", + resolve: async () => ({ addresses: ["198.51.100.9"], failed: false }), + check: async () => true, + }); + + expect(result.secure).toBe(false); + expect(result.reason).toMatch(/does not point at this server/i); + }); + + it("still accepts a custom domain whose name simply has no records yet", async () => { + // The resolver answered. A name minutes old has nothing to say and the + // certificate wait is the real test, which is not the same as Dyad never + // having got an answer at all. + const { session } = fakeSession(); + const result = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "coolify.example.com", + resolve: async () => ({ addresses: [], failed: false }), + check: async () => true, + }); + + expect(result.secure).toBe(true); + expect(result.instanceUrl).toBe("https://coolify.example.com"); + }); + + it("says what it is doing while it takes the domain back off", async () => { + // The only stretch with nothing behind it on screen. On a slow server a + // silent wait here reads as a hang. + const { session } = fakeSession(); + const said: string[] = []; + + await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + check: async () => false, + onProgress: (message) => said.push(message), + }); + + expect(said.join("")).toMatch(/Removing the temporary domain/); + }); + + it("only says applied if the write and the proxy rebuild both ran", async () => { + // Tinker goes on to the next line when a statement throws, so a marker + // standing on its own would report a domain that was never set. One + // statement means a throw takes the marker with it. + const { session } = fakeSession(); + const scripts: string[] = []; + session.run = (async (_command: string, options?: { input?: string }) => { + scripts.push(options?.input ?? ""); + return { code: 0, stdout: transcript("applied"), stderr: "" }; + }) as unknown as SshSession["run"]; + + await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + check: async () => true, + }); + + const applying = scripts.find((script) => script.includes("fqdn")) ?? ""; + // The save, the proxy rebuild and the marker are one statement, so the + // marker cannot be reached without them. + expect(applying).toContain("return 'applied'"); + expect(applying).not.toMatch(/echo 'applied'/); + const oneLine = applying.split("\n").find((line) => line.includes("fqdn"))!; + expect(oneLine).toContain("save()"); + expect(oneLine).toContain("setupDynamicProxyConfiguration()"); + expect(oneLine).toContain("return 'applied'"); + }); + + it("reads applied past a notice, and not out of the echoed script", async () => { + // The script it sends contains the word, so matching anywhere in the + // region would take the echo of the line that printed it. + const { session } = fakeSession(); + session.run = (async () => ({ + code: 0, + stdout: transcript("PHP Deprecated: something\napplied"), + stderr: "", + })) as unknown as SshSession["run"]; + + await expect( + applyInstanceDomain(session, "coolify.example.com"), + ).resolves.toBeUndefined(); + }); + + it("does not take the word out of the middle of a line", async () => { + // The script it sends carries the word, so a region holding the echo of + // that line — and no answer of its own — is not the server saying yes. + const { session } = fakeSession(); + session.run = (async () => ({ + code: 0, + stdout: transcript("> echo (function () { return 'applied'; })();"), + stderr: "", + })) as unknown as SshSession["run"]; + + await expect( + applyInstanceDomain(session, "coolify.example.com"), + ).rejects.toThrow(); + }); + + it("does not call a vetoed save applied", async () => { + // Eloquent answers false rather than throwing when a model event stops + // the write, so a run that reported success would go on to probe for a + // certificate against a domain Coolify never took. + const { session } = fakeSession(); + session.run = (async () => ({ + code: 0, + stdout: transcript("not-saved"), + stderr: "", + })) as unknown as SshSession["run"]; + + await expect( + applyInstanceDomain(session, "coolify.example.com"), + ).rejects.toThrow(); + }); + + it("asks whether the save was taken, not only whether it threw", async () => { + // The answer above only matters if the script can produce it. Eloquent + // returns false rather than throwing when a model event stops the write, + // which the one-statement form does not catch on its own. + const { session, scripts } = fakeSession(); + await applyInstanceDomain(session, "coolify.example.com"); + + expect(scripts[0]).toContain("if (!$s->save())"); + expect(scripts[0]).toContain("return 'not-saved'"); + }); + + it("says so when the domain will not come back off", async () => { + // The state the revert exists to avoid, arrived at anyway. Left silent, + // the last thing said was that the domain was being removed — which + // reads as it having worked, over a Coolify still set to answer at a + // name it has no certificate for. + const said: string[] = []; + const { session } = fakeSession(); + const answering = session.run as unknown as ( + command: string, + options?: { input?: string }, + ) => Promise; + let domainWrites = 0; + session.run = (async (command: string, options?: { input?: string }) => { + // The first write puts the domain on; the second takes it back off, + // and that is the one this is about. + if ((options?.input ?? "").includes("fqdn")) { + domainWrites += 1; + if (domainWrites > 1) throw new Error("tinker is wedged"); + } + return answering(command, options); + }) as unknown as SshSession["run"]; + + const result = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + check: async () => false, + onProgress: (message) => said.push(message), + }); + + expect(said.join("")).toMatch(/Could not remove the temporary domain/); + // And carried where the panel will show it, not only in the log. + expect(result.reason).toMatch(/may still be configured for/); + expect(result.reason).toMatch(/clear the instance domain/); + // The install still stands: this is the way out of a failure, not a new + // one to throw. + expect(result.secure).toBe(false); + expect(result.instanceUrl).toBe("http://203.0.113.5:8000"); + }); + + it("puts what is left to undo on a cancellation as it leaves", async () => { + // The panel says nothing about a cancelled run, so a domain that would + // not come back off would otherwise be reported only into a log the user + // is never shown. + const { session } = fakeSession(); + const answering = session.run as unknown as ( + command: string, + options?: { input?: string }, + ) => Promise; + const controller = new AbortController(); + let domainWrites = 0; + session.run = (async (command: string, options?: { input?: string }) => { + if ((options?.input ?? "").includes("fqdn")) { + domainWrites += 1; + // The domain goes on, then the user cancels, then taking it back off + // is what fails. + if (domainWrites === 1) { + const answer = await answering(command, options); + controller.abort(); + return answer; + } + throw new Error("tinker is wedged"); + } + return answering(command, options); + }) as unknown as SshSession["run"]; + + const error = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + check: async () => false, + signal: controller.signal, + }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(Error); + expect((error as Error & { warning?: string }).warning).toMatch( + /may still be configured for/, + ); + }); + + it("gives the revert the full budget when nobody is waiting on a cancel", async () => { + // The short bound exists so "Stopping…" cannot hang. On the ordinary + // no-certificate path there is no cancel, and the domain still has to + // come off. + const asked: Array = []; + const session = { + run: vi.fn(async (_c: string, o?: { timeoutMs?: number }) => { + asked.push(o?.timeoutMs); + return { code: 0, stdout: transcript("applied"), stderr: "" }; + }) as unknown as SshSession["run"], + end: vi.fn(), + }; + + await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + check: async () => false, + }); + + expect(asked[0]).toBe(asked[1]); + }); + + it("does not wait for a certificate a private name can never get", async () => { + // The certificate poll is two minutes, and a LAN name pays all of it for + // an answer no certificate authority can give. + const { session, scripts } = fakeSession(); + const outcome = await tryEnableHttps(session, "box.homelab.lan", { + ...FAST, + resolve: async () => ({ addresses: ["192.168.1.50"], failed: false }), + check: async () => true, + }); + + expect(outcome.secure).toBe(false); + expect(outcome.reason).toMatch(/cannot reach/); + // Nothing was applied, so nothing has to be taken back off. + expect(scripts).toHaveLength(0); + }); + + it("still tries when the name resolves somewhere public", async () => { + const { session } = fakeSession(); + const outcome = await tryEnableHttps(session, "box.example.com", { + ...FAST, + resolve: async () => ({ addresses: ["203.0.113.5"], failed: false }), + check: async () => true, + }); + + expect(outcome.secure).toBe(true); + }); + + it("still tries when the name cannot be resolved at all", async () => { + // Not knowing where a server is is not the same as knowing it is private. + const { session } = fakeSession(); + const outcome = await tryEnableHttps(session, "box.internal", { + ...FAST, + resolve: async () => ({ addresses: [], failed: true }), + check: async () => true, + }); + + expect(outcome.secure).toBe(true); + }); + + it("takes the domain back off when the cancel lands while it is being set", async () => { + // The script sets the fqdn before it rebuilds the proxy, so a cancel here + // has almost certainly already been written to the instance. + const controller = new AbortController(); + const scripts: string[] = []; + let applies = 0; + const session = { + run: vi.fn(async (_command: string, options?: { input?: string }) => { + scripts.push(options?.input ?? ""); + applies += 1; + if (applies === 1) { + controller.abort(); + throw new DyadError("Cancelled.", DyadErrorKind.UserCancelled); + } + return { code: 0, stdout: transcript("applied"), stderr: "" }; + }) as unknown as SshSession["run"], + end: vi.fn(), + }; + + await expect( + tryEnableHttps(session, "203.0.113.5", { + ...FAST, + signal: controller.signal, + check: async () => false, + }), + ).rejects.toThrow(/Cancelled/); + + expect(scripts.some((t) => t.includes("fqdn = null"))).toBe(true); + }); + + it("takes the domain back off when the user cancels the wait", async () => { + // The domain is set before the certificate is asked for. Cancelling in + // between and leaving it there points the dashboard at a name that serves + // nothing. + const { session, scripts } = fakeSession(); + const controller = new AbortController(); + controller.abort(); + + await expect( + tryEnableHttps(session, "203.0.113.5", { + ...FAST, + signal: controller.signal, + check: async () => false, + }), + ).rejects.toThrow(/Cancelled/); + + expect(scripts.some((t) => t.includes("fqdn = null"))).toBe(true); + }); + + it("reports the encrypted address once a certificate arrives", async () => { + const { session } = fakeSession(); + const outcome = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + check: async () => true, + }); + + expect(outcome).toEqual({ + instanceUrl: "https://203.0.113.5.sslip.io", + secure: true, + }); + }); + + it("takes the domain back off when no certificate arrives", async () => { + // Left pointed at a domain with no certificate, Coolify would answer its + // own address with an error — a server nobody can open, which is worse + // than one that is merely unencrypted. + const { session, scripts } = fakeSession(); + const outcome = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + check: async () => false, + }); + + expect(outcome.secure).toBe(false); + expect(outcome.instanceUrl).toBe("http://203.0.113.5:8000"); + expect(outcome.reason).toBeTruthy(); + // The last thing it did was clear the domain again. + expect(scripts.at(-1)).toContain("$s->fqdn = null;"); + }); + + it("does not point Coolify at a domain that is somewhere else", async () => { + // Applying it would take the dashboard off its own address, and the + // certificate check would pass against the host that answers there. + const { session, scripts } = fakeSession(); + const outcome = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "example.com", + resolve: async () => ({ addresses: ["198.51.100.9"], failed: false }), + check: async () => true, + }); + + expect(outcome.secure).toBe(false); + expect(outcome.instanceUrl).toBe("http://203.0.113.5:8000"); + expect(outcome.reason).toContain("does not point at this server"); + expect(scripts).toHaveLength(0); + }); + + it("still uses the derived name without asking DNS about it", async () => { + // It is built from the address, so it resolves there by construction. + const resolve = vi.fn(); + const { session } = fakeSession(); + const outcome = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + resolve: resolve as never, + check: async () => true, + }); + + expect(outcome.secure).toBe(true); + expect(resolve).not.toHaveBeenCalled(); + }); + + it("explains a failed custom domain differently from a shared one", async () => { + // A domain of the user's own fails for a reason they can act on; the free + // shared one fails for a reason they cannot. + const { session } = fakeSession(); + const mine = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + customDomain: "coolify.example.com", + resolve: async () => ({ addresses: ["203.0.113.5"], failed: false }), + check: async () => false, + }); + expect(mine.reason).toContain("points at this server"); + + const shared = await tryEnableHttps(session, "203.0.113.5", { + ...FAST, + check: async () => false, + }); + expect(shared.reason).toContain("allowance"); + + // A server given by name uses that name directly, so the free service is + // not involved and blaming its allowance describes nothing. + const named = await tryEnableHttps(session, "coolify.example.com", { + ...FAST, + check: async () => false, + }); + expect(named.reason).not.toContain("allowance"); + expect(named.reason).toContain("points at this server"); + }); + + it("does not touch the instance when there is no domain to ask for", async () => { + const { session, scripts } = fakeSession(); + const outcome = await tryEnableHttps(session, "2606:4700::1", FAST); + + expect(outcome.secure).toBe(false); + expect(scripts).toHaveLength(0); + }); + + it("stops when cancelled", async () => { + const { session } = fakeSession(); + const controller = new AbortController(); + controller.abort(); + + await expect( + tryEnableHttps(session, "203.0.113.5", { + ...FAST, + signal: controller.signal, + check: async () => false, + }), + ).rejects.toMatchObject({ kind: "user_cancelled" }); + }); +}); diff --git a/src/coolify_setup/https_setup.ts b/src/coolify_setup/https_setup.ts new file mode 100644 index 0000000000..0719640f01 --- /dev/null +++ b/src/coolify_setup/https_setup.ts @@ -0,0 +1,538 @@ +import log from "electron-log"; +import { isIP } from "node:net"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { sleep } from "./sleep"; +import type { SshSession } from "@/ipc/utils/ssh_client"; +import { answerLine, runTinker } from "./tinker"; +import { IS_TEST_BUILD } from "@/ipc/utils/test_utils"; +import { isPlausibleInstanceDomain } from "@/shared/coolify_domain"; +import { resolveBoth } from "@/ipc/utils/dns_resolve"; +import { + domainCheckVerdict, + isLoopbackAddress, + isNonRoutableAddress, +} from "@/shared/domain_check"; + +/** + * Getting the new instance onto HTTPS, if it can be had. + * + * A stock Coolify serves plain HTTP, so Dyad's API token — which carries root + * abilities and can read database connection strings — would cross the network + * in the clear on every deploy, not just once at setup. + * + * Coolify already knows how to fix this: give it a domain and its proxy asks + * Let's Encrypt for a certificate. The missing piece is the domain, and a + * server's own address is one: `1.2.3.4.sslip.io` resolves to 1.2.3.4 with + * nothing to configure. + * + * Attempted rather than required. Certificates depend on a third party that can + * refuse, so this checks whether it worked and puts the instance back on plain + * HTTP if it did not. Verified against Coolify 4.3.2: a domain that cannot be + * validated leaves port 8000 serving normally, so the fallback is a real one. + */ + +/** Long enough for the proxy to be rebuilt, short enough to give up on. */ +const APPLY_DOMAIN_TIMEOUT_MS = 60_000; +/** + * Shorter, and only when a cancel is waiting on it. + * + * The revert ignores the abort signal — it is undoing what the cancel + * interrupted — so this bounds how long a cancel already waiting is held. + * A cancel that lands once the revert is under way waits for it: giving up + * part-way is worse than the delay. Every other exit gets the same budget as + * the apply, because nobody is being held up. + */ +const CANCELLED_REVERT_TIMEOUT_MS = 10_000; + +/** It answered in about fifteen seconds on a new server; this is slack. */ +const CERTIFICATE_TIMEOUT_MS = 120_000; +const POLL_INTERVAL_MS = 6_000; + +/** + * The name to ask for a certificate for. + * + * A hostname the user gave us is already a domain and is the better answer: + * it is theirs, and it does not spend anyone else's certificate allowance. Only + * a bare address needs sslip.io, which exists precisely so an address can be + * spelled as a name. + */ +export function certificateDomainFor( + host: string, + customDomain?: string | null, +): string | null { + const custom = customDomain?.trim(); + if (custom) { + const bareCustom = custom.replace(/^https?:\/\//, "").replace(/\/+$/, ""); + // The same reasoning as the derived names below, which a domain given by + // hand needs just as much: nothing public can validate a name only this + // machine answers to, and asking anyway spends the whole certificate wait + // on an answer that cannot arrive. + if (isLoopbackAddress(bareCustom) || /\.local$/i.test(bareCustom)) { + return null; + } + if (isIP(bareCustom) === 4) { + if (isNonRoutableAddress(bareCustom)) return null; + // A bare address is not a name any authority will certify, so asking + // for one spends the whole wait on a refusal. The derived spelling of + // the same address is a name, and is what the address would have been + // turned into had it been left out of the domain field. + return `${bareCustom}.sslip.io`; + } + if (isIP(bareCustom) === 6) return null; + return bareCustom; + } + + const bare = host.trim(); + if (isIP(bare) === 4) { + // A certificate authority validates over the public internet, so a name + // that resolves to a private or loopback address can never be given one. + // Deriving it anyway meant waiting out the whole certificate poll — two + // minutes — for an answer that could not arrive, which is what a homelab + // server on a LAN address would do every time. + if (isLoopbackAddress(bare) || isNonRoutableAddress(bare)) return null; + return `${bare}.sslip.io`; + } + // A name only this machine or this network answers to is in the same + // position as a private address: nothing public can validate it, and asking + // anyway costs the whole certificate wait. mDNS names are spelled out here + // rather than folded into isLoopbackAddress, which means something narrower + // — a .local is a real machine, just not one the internet can reach. + if (isLoopbackAddress(bare) || /\.local$/i.test(bare)) return null; + // IPv6 has an sslip.io spelling with dashes, but Coolify's proxy and the + // certificate authority both have more to say about IPv6 than is worth + // guessing at here. A name is used as given. + if (isIP(bare) === 6) return null; + return bare || null; +} + +export function httpsUrlFor(domain: string): string { + return `https://${domain}`; +} + +/** Bracketed when it needs to be, or the colons read as a port. */ +export function urlHost(host: string): string { + return isIP(host) === 6 ? `[${host}]` : host; +} + +/** + * Coolify's own port, or the one an e2e build was told to use. + * + * Fixed in production because Coolify's installer fixes it. Named by the + * environment under a test build so parallel workers do not have to share it. + */ +function dashboardPort(): number { + const override = IS_TEST_BUILD ? process.env.DYAD_E2E_DASHBOARD_PORT : null; + return override ? Number(override) : 8000; +} + +/** + * Where the dashboard answers. + * + * The same address the setup stores and shows the user, from one definition: + * two copies meant a change to the port could move only half of them. + */ +export function plainUrlFor(host: string): string { + return `http://${urlHost(host)}:${dashboardPort()}`; +} + +/** + * Points Coolify at a domain and reconfigures its proxy. + * + * Both halves are needed: the setting alone changes nothing until the proxy is + * rebuilt, which is what asks for the certificate. + * + * WORKAROUND: only the dashboard can set this, so it writes the model field + * and calls the proxy rebuild the dashboard calls. + * + * TODO: replace if Coolify gains an endpoint or command that sets the instance + * domain and reconfigures the proxy. + */ +export async function applyInstanceDomain( + session: SshSession, + domain: string | null, + { + signal, + timeoutMs = APPLY_DOMAIN_TIMEOUT_MS, + }: { signal?: AbortSignal; timeoutMs?: number } = {}, +): Promise { + if (domain !== null && !isPlausibleInstanceDomain(domain)) { + throw new DyadError( + `Refusing to set an unsafe instance domain: ${domain}`, + DyadErrorKind.Validation, + ); + } + const answer = await runTinker( + session, + // One statement, so nothing after a throw runs. Tinker carries on to the + // next line when a statement fails, which for a marker on its own line + // means the write can fail and still report success — and this marker is + // what the caller reads to decide the domain went on, or came back off. + // Server 0 is the machine Coolify runs on, which is the one serving the + // dashboard this domain points at. + `echo (function () { $s = \\App\\Models\\InstanceSettings::get(); ` + + (domain === null + ? `$s->fqdn = null; ` + : `$s->fqdn = 'https://' . getenv('DYAD_INSTANCE_DOMAIN'); `) + + // Eloquent answers false rather than throwing when something vetoes + // the write, so the throw-safety above is not enough on its own. + `if (!$s->save()) { return 'not-saved'; } ` + + `\\App\\Models\\Server::find(0)->setupDynamicProxyConfiguration(); ` + + `return 'applied'; })();`, + { + env: domain === null ? {} : { DYAD_INSTANCE_DOMAIN: domain }, + signal, + // Bounded: the proxy rebuild is the slow part, and a server that never + // answers leaves "Setting up HTTPS" on screen with nothing behind it. + timeoutMs, + }, + ); + // Checked, because a Coolify that refused still prints and still ends. Left + // unread, its error became a two-minute wait blamed on the certificate + // authority. One line of the answer, like the other readers: tolerant of a + // notice beside it, and tight enough that the echoed script line — which + // carries the word — cannot pass for the answer. + if (!answerLine(answer, (line) => line === "applied")) { + throw new DyadError( + `Coolify would not take the domain: ${answer.trim()}`, + DyadErrorKind.External, + ); + } +} + +/** + * Whether the address serves HTTPS with a certificate this machine trusts. + * + * Node rejects an untrusted certificate rather than reporting it, so a request + * that resolves at all is the check. A self-signed certificate would fail here, + * which is the point: it would fail in the user's browser too. + */ +export async function hasTrustedCertificate( + url: string, + { fetchImpl = fetch }: { fetchImpl?: typeof fetch } = {}, +): Promise { + try { + const res = await fetchImpl(url, { + method: "GET", + redirect: "manual", + signal: AbortSignal.timeout(10_000), + }); + return res.status > 0; + } catch { + return false; + } +} + +/** + * Whether a domain the user gave us points at the server being set up. + * + * Asked before the domain is applied, because a certificate is not the same + * question as "is this our server". A name still pointing at the user's old + * site answers HTTPS on the first poll with a valid certificate of its own, + * and taking that as success would store that host as Coolify's address — and + * send it an API token that carries root abilities. + * + * Advisory in one direction only: a resolver we could not reach says nothing, + * so it is not treated as a wrong answer. + */ +export async function domainPointsAtServer( + domain: string, + host: string, + { + resolve = resolveBoth, + hostAddresses, + }: { resolve?: typeof resolveBoth; hostAddresses?: string[] } = {}, +): Promise< + | "points-here" + | "points-elsewhere" + | "no-answer" + | "server-unresolved" + | "different-families" +> { + // A server known by a name is resolved to the addresses it stands for, so + // the domain is compared against the same thing either way. A name that + // does not resolve leaves nothing to compare, which the verdict below reads + // as not knowing rather than as a wrong answer. + const expectedIps = + isIP(host) !== 0 + ? [host] + : (hostAddresses ?? (await resolve(host)).addresses); + const resolved = await resolve(domain); + // A resolver that could not be reached is not a name with no records. Both + // arrive with nothing to compare, but only the second is the domain saying + // where it points — the first is Dyad not having asked successfully, which + // the caller has to be able to tell apart from an answer. + if (resolved.failed) return "no-answer"; + const verdict = domainCheckVerdict({ + expectedIps, + actualIps: resolved.addresses, + }); + if (verdict === "points-elsewhere") return "points-elsewhere"; + // Two different things arrive as "unknown", and neither is an answer. + // + // With addresses on both sides it is records that cannot be compared, + // because they are in different families. With none on ours it is the + // server's own name that did not resolve — a name only this machine or + // this network answers to, which plain DNS cannot see and connectSsh + // reaches anyway. Accepting there compared a user's domain against + // nothing at all, which is the whole of what this function is for. + // Nothing on our side to hold the domain against, whatever the domain + // turned out to say. domainCheckVerdict answers about the domain first — a + // name with no records is a fact about it, true whatever the server's + // address is — so that answer arrives before it ever looks at ours, and + // reading it as agreement would compare against nothing at all. + if (expectedIps.length === 0) return "server-unresolved"; + if (verdict === "unknown") { + // Both sides answered, in families that cannot meet. A different thing + // from either lookup coming back empty, and a different remedy. + return "different-families"; + } + return "points-here"; +} + +const logger = log.scope("coolify_https_setup"); + +export interface HttpsOutcome { + /** What Dyad should store and talk to. */ + instanceUrl: string; + secure: boolean; + /** Present when HTTPS was attempted and did not arrive. */ + reason?: string; +} + +/** + * Whether a name stands for something a certificate authority could reach. + * + * Only a definite private answer says no. A resolver that cannot answer, or a + * name with no records yet, leaves this true — refusing there would decline + * HTTPS for a server that could have had it. + */ +function resolvesPublicly(addresses: string[]): boolean { + if (addresses.length === 0) return true; + return addresses.some( + (address) => !isLoopbackAddress(address) && !isNonRoutableAddress(address), + ); +} + +/** + * Tries to put the instance on HTTPS, and settles for HTTP if it cannot. + * + * The instance is left on plain HTTP rather than pointed at a domain with no + * certificate: a half-configured proxy would answer the dashboard's own address + * with an error, and a working server nobody can open is worse than one that is + * merely unencrypted. + */ +export async function tryEnableHttps( + session: SshSession, + host: string, + { + customDomain, + signal, + timeoutMs = CERTIFICATE_TIMEOUT_MS, + intervalMs = POLL_INTERVAL_MS, + now = () => Date.now(), + check = hasTrustedCertificate, + resolve = resolveBoth, + onProgress, + }: { + customDomain?: string | null; + signal?: AbortSignal; + timeoutMs?: number; + intervalMs?: number; + now?: () => number; + check?: typeof hasTrustedCertificate; + resolve?: typeof resolveBoth; + onProgress?: (message: string) => void; + } = {}, +): Promise { + const domain = certificateDomainFor(host, customDomain); + if (!domain) { + return { + instanceUrl: plainUrlFor(host), + secure: false, + // Says which of the two was refused. Blaming the address for a domain + // the user typed sends them to check the thing that was fine. + reason: customDomain?.trim() + ? `${customDomain.trim()} cannot be given a certificate, because ` + + `nothing on the public internet can reach it to check.` + : "This address cannot be given a certificate.", + }; + } + + // A name is only as reachable as what it stands for. Asked before the + // domain is applied, because a server on a LAN address would otherwise + // apply it, rebuild the proxy, wait out the whole certificate poll for an + // answer that cannot come, and take it all back off again. + const hostAddresses = + isIP(host) === 0 ? (await resolve(host)).addresses : [host]; + if (!resolvesPublicly(hostAddresses)) { + return { + instanceUrl: plainUrlFor(host), + secure: false, + reason: `${host} resolves to an address the public internet cannot reach, so no certificate can be issued for it.`, + }; + } + + // Only a domain of the user's own can point somewhere else. A derived + // sslip.io name resolves to the address it was built from, by construction + // — including one derived from an address typed into the domain field, + // which is why this asks what the domain turned out to be rather than + // whether the field was filled. An address typed there that is not this + // server is still the user's own, and is still checked. + if (customDomain && domain !== `${host}.sslip.io`) { + const points = await domainPointsAtServer(domain, host, { + resolve, + hostAddresses, + }); + if (points === "points-elsewhere") { + return { + instanceUrl: plainUrlFor(host), + secure: false, + reason: + `${domain} does not point at this server, so a certificate for it ` + + `would not describe this machine. Point it at ${host} and set the ` + + `domain in Coolify.`, + }; + } + // Not knowing is not permission. The certificate poll below settles for + // any address that answers with a certificate it trusts, and it resolves + // the name through the system rather than the resolver asked here — so a + // domain still pointing at a machine the user is moving off would be + // taken as proof, and its address stored as the instance to send the API + // token to on every deploy. + if (points === "no-answer") { + return { + instanceUrl: plainUrlFor(host), + secure: false, + reason: + `Dyad could not look up where ${domain} points, so it cannot tell ` + + `whether a certificate for it would describe this machine. Check ` + + `the domain resolves to ${host} and try again.`, + }; + } + // Nothing came back for the server itself, so there is nothing to hold + // the domain against — which is not the domain's fault and may not be a + // fault at all: a name only this network answers to is reached over SSH + // and invisible to plain DNS. + if (points === "server-unresolved") { + return { + instanceUrl: plainUrlFor(host), + secure: false, + reason: + `Dyad could not look up an address for ${host}, so it cannot tell ` + + `whether ${domain} points at this server. Reach the server by an ` + + `address or a name DNS can answer for, or set the domain in ` + + `Coolify yourself.`, + }; + } + // Addresses came back for both, with no family in common — so there is + // nothing to compare, and which side is which varies. Naming a family + // here, or saying the domain has only those records, would state as fact + // something never established: one family's lookup can fail while the + // other answers, and resolveBoth reports that as an answer. + if (points === "different-families") { + return { + instanceUrl: plainUrlFor(host), + secure: false, + reason: + `Dyad could not compare where ${domain} points with ${host}: the ` + + `addresses it has for them are in different families, so neither ` + + `says anything about the other. Give the server's address in the ` + + `same family as the domain's records, or set the domain in ` + + `Coolify yourself.`, + }; + } + } + + const url = httpsUrlFor(domain); + onProgress?.(`Requesting a certificate for ${domain}…\n`); + + // True only when the domain earned its place. Every other way out of the + // block below — no certificate, a cancel, a failure part-way through + // applying it — leaves Coolify answering at a name that serves nothing, so + // the domain comes back off before anyone is told what happened. + let keepDomain = false; + let settled: HttpsOutcome | null = null; + // Held so the revert below can add to whatever is on its way out. A cancel + // leaves by throwing, and its message is all the panel keeps. + let thrown: unknown; + try { + await applyInstanceDomain(session, domain, { signal }); + + const deadline = now() + timeoutMs; + while (now() < deadline) { + if (signal?.aborted) { + throw new DyadError("Cancelled.", DyadErrorKind.UserCancelled); + } + if (await check(url)) { + keepDomain = true; + onProgress?.(`Coolify is available over HTTPS at ${url}\n`); + return { instanceUrl: url, secure: true }; + } + await sleep(intervalMs, signal); + } + + onProgress?.("No certificate arrived; leaving Coolify on plain HTTP.\n"); + // Held rather than returned outright so the revert below can add to it. + // The caller has this object, not a copy of it. + settled = { + instanceUrl: plainUrlFor(host), + secure: false, + reason: !domain.endsWith(".sslip.io") + ? `No certificate was issued for ${domain}. Check that it points at this server.` + : `No certificate was issued for ${domain}. The free service that ` + + `provides these names shares one certificate allowance between ` + + `everyone using it, and it can run out.`, + }; + return settled; + } catch (error) { + thrown = error; + throw error; + } finally { + // Without the signal, which by this point may be the reason we are here. + // Bounded by the tinker call's own timeout, so a wedged server cannot + // hold the cancel open. + if (!keepDomain) { + // Said out loud: this is the one stretch where the panel has nothing + // behind it, and on a slow server a silent wait reads as a hang. + onProgress?.("Removing the temporary domain…\n"); + // Not raced against the signal. A cancel landing mid-revert waits for + // it, because abandoning a proxy rebuild half-way leaves Coolify + // answering at a name with no certificate — which is the state this + // whole block exists to avoid. + await applyInstanceDomain(session, null, { + timeoutMs: signal?.aborted + ? CANCELLED_REVERT_TIMEOUT_MS + : APPLY_DOMAIN_TIMEOUT_MS, + }).catch((error: unknown) => { + // The state the comment above calls the one to avoid, arrived at + // anyway. Swallowed, because the install itself stands and this is + // the way out of a failure rather than the failure — but not + // silently: the last thing the log said was that the domain was + // being removed, which reads as it having worked. + logger.error(`Could not remove the temporary domain ${domain}`, error); + onProgress?.( + `Could not remove the temporary domain ${domain}. Coolify may ` + + `still be set to answer at it.\n`, + ); + // A cancel leaves this way, and the panel shows nothing for a + // cancelled run — so without this the one thing the user has to act + // on would be said only into a log nobody is shown. + if (thrown instanceof Error) { + (thrown as Error & { warning?: string }).warning = + `Coolify may still be configured for ${domain}; clear the ` + + `instance domain in its settings if the dashboard does not ` + + `answer at ${plainUrlFor(host)}.`; + } + if (settled) { + // Wrapped, so the trim applies to the joined sentence rather than + // binding to the last piece of it. + settled.reason = ( + `${settled.reason ?? ""} Coolify may still be configured for ` + + `${domain}; clear the instance domain in its settings if the ` + + `dashboard does not answer at ${plainUrlFor(host)}.` + ).trimStart(); + } + }); + } + } +} diff --git a/src/coolify_setup/install.test.ts b/src/coolify_setup/install.test.ts new file mode 100644 index 0000000000..fe1d9647c0 --- /dev/null +++ b/src/coolify_setup/install.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildInstallScript, + installCoolify, + preflight, + waitForAdminSeeded, +} from "./install"; +import { SshError } from "@/ipc/utils/ssh_client"; +import type { SshSession } from "@/ipc/utils/ssh_client"; +import { DyadErrorKind } from "@/errors/dyad_error"; + +/** + * What Dyad concludes when a server does not answer properly. + * + * The interesting cases here are not the ones where a server says something + * unexpected — they are the ones where it says nothing at all, because every + * answer is read out of one transcript and an empty transcript still parses. + */ + +function sessionAnswering(run: SshSession["run"]): SshSession { + return { run, end: vi.fn() } as unknown as SshSession; +} + +const HEALTHY = "mem=1967\ncontainer=\nbusy=no"; + +/** What a tinker script's output looks like coming back off the wire. */ +function transcript(output: string): string { + return [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "> __DYAD_OUT_START__", + output, + "__DYAD_OUT_END__", + ].join("\n"); +} + +describe("what the installer is sent", () => { + const CREDENTIALS = { + username: "dyad", + email: "me@gmail.com", + password: "Abc123@xyz", + }; + + it("keeps the password out of the command line", async () => { + // Anyone with a shell on that machine can read a command line out of ps. + let sentCommand = ""; + let sentInput: string | undefined; + const session = { + run: async (command: string, options?: { input?: string }) => { + sentCommand = command; + sentInput = options?.input; + return { code: 0, stdout: "", stderr: "" }; + }, + end: () => {}, + }; + + await installCoolify(session as never, CREDENTIALS); + + expect(sentCommand).not.toContain(CREDENTIALS.password); + expect(sentInput).toContain(CREDENTIALS.password); + }); + + it("fails the install when the download fails", () => { + // curl reports the failure and bash, handed nothing, exits 0 — so without + // this the pipeline's status is 0 and a server with no Coolify on it + // reads as installed. + expect(buildInstallScript(CREDENTIALS)).toContain("set -o pipefail"); + }); + + it("feeds the script over stdin rather than as arguments", async () => { + let sent: string | undefined; + const session = { + run: async (_command: string, options?: { input?: string }) => { + sent = options?.input; + return { code: 0, stdout: "", stderr: "" }; + }, + end: () => {}, + }; + + await installCoolify(session as never, CREDENTIALS); + + expect(sent).toContain(CREDENTIALS.password); + }); + + it("refuses a credential that could end its own quoting", () => { + expect(() => + buildInstallScript({ ...CREDENTIALS, password: "a'; rm -rf /" }), + ).toThrow(); + }); +}); + +describe("preflight", () => { + it("reads a healthy server as ready", async () => { + const session = sessionAnswering( + vi.fn(async () => ({ code: 0, stdout: HEALTHY, stderr: "" })) as never, + ); + await expect(preflight(session)).resolves.toMatchObject({ + ready: true, + alreadyInstalled: false, + memoryMb: 1967, + }); + }); + + it("refuses when docker is there but will not say what it is running", async () => { + // A stopped daemon reports no containers, which reads exactly like a + // machine with no Coolify — and installing over an instance that is + // merely stopped is the outcome worth refusing. + const session = sessionAnswering( + vi.fn(async () => ({ + code: 0, + stdout: "mem=1967\ncontainer=\ndockerok=no\nbusy=no\n", + stderr: "", + })) as never, + ); + + await expect(preflight(session)).resolves.toMatchObject({ + ready: false, + alreadyInstalled: false, + }); + }); + + it("refuses a probe that came back with nothing", async () => { + // An empty transcript parses as "no memory, no container, not busy" — + // which reads as a healthy empty server, and that is the one wrong answer + // that matters: it stands between the user and installing over a Coolify + // that is already there. + const session = sessionAnswering( + vi.fn(async () => ({ code: 1, stdout: "", stderr: "" })) as never, + ); + const checks = await preflight(session); + + expect(checks.ready).toBe(false); + expect(checks.alreadyInstalled).toBe(false); + expect(checks.reason).toContain("could not read"); + }); + + it("refuses a server whose memory it could not read", async () => { + // A server that answers the question but not this part of it sends back + // `mem=` — empty, not missing, so the whole-transcript guard above does + // not fire and the size check has nothing to compare. Read as ready, the + // 2GB rule is quietly absent: the install finishes and Coolify does not + // run, on a machine that now refuses a second attempt. + const session = sessionAnswering( + vi.fn(async () => ({ + code: 0, + stdout: "mem=\ncontainer=\nbusy=no", + stderr: "", + })) as never, + ); + const checks = await preflight(session); + + expect(checks.ready).toBe(false); + expect(checks.memoryMb).toBeNull(); + expect(checks.reason).toContain("could not read how much memory"); + // It answered about Coolify, so that part is not in doubt. + expect(checks.installedKnown).toBe(true); + }); + + it("still reports a server that is busy", async () => { + const session = sessionAnswering( + vi.fn(async () => ({ + code: 0, + stdout: "mem=1967\ncontainer=\nbusy=yes", + stderr: "", + })) as never, + ); + await expect(preflight(session)).resolves.toMatchObject({ ready: false }); + }); +}); + +describe("a server that answers the connection but not the question", () => { + it("gives up on the probe rather than leaving the step running", async () => { + // A wedged docker answers nothing. Without a bound the panel sits on + // "Checking the server" until the user works out that nothing is + // happening and stops it themselves. + const asked: Array = []; + const session = sessionAnswering( + vi.fn(async (_c: string, options?: { timeoutMs?: number }) => { + asked.push(options?.timeoutMs); + return { code: 0, stdout: HEALTHY, stderr: "" }; + }) as never, + ); + + await preflight(session); + expect(asked[0]).toBeGreaterThan(0); + }); + + it("leaves the installer alone, which legitimately takes minutes", async () => { + const asked: Array = []; + const session = sessionAnswering( + vi.fn(async (command: string, options?: { timeoutMs?: number }) => { + if (command.includes("install.sh")) asked.push(options?.timeoutMs); + return { code: 0, stdout: "", stderr: "" }; + }) as never, + ); + + await installCoolify(session, { + username: "dyad-admin", + email: "me@gmail.com", + password: "Abc123@xyz", + }); + expect(asked).toEqual([undefined]); + }); +}); + +describe("waiting for the admin account", () => { + it("asks with a bound, so one hung attempt cannot outlast the loop", async () => { + // The deadline is only looked at between attempts, so an unbounded + // question outlasts every bound there is. The bound belongs in the + // command — giving up on the answer should also stop the asking — so + // what is checked here is that the question carries one. + const asked: Array<{ timeoutMs?: number }> = []; + const session = sessionAnswering( + vi.fn(async (_command: string, options?: { timeoutMs?: number }) => { + asked.push({ timeoutMs: options?.timeoutMs }); + return { code: 0, stdout: transcript("yes"), stderr: "" }; + }) as never, + ); + + await expect( + waitForAdminSeeded(session, "me@gmail.com", { + timeoutMs: 2_000, + intervalMs: 1, + attemptTimeoutMs: 20, + }), + ).resolves.toEqual({ seeded: true }); + + expect(asked[0]?.timeoutMs).toBe(20); + }); + + it("hands back a server to sign in to when the seeder itself dies", async () => { + // Coolify is on the machine either way. Ending the run here would report + // an install that did not happen and take the password Dyad invented down + // with it, when the honest answer is to go and sign in by hand. + const session = sessionAnswering( + vi.fn(async (command: string) => { + if (command.includes("RootUserSeeder")) throw new Error("wedged"); + return { code: 0, stdout: transcript("no"), stderr: "" }; + }) as never, + ); + + await expect( + waitForAdminSeeded(session, "me@gmail.com", { + timeoutMs: 20, + intervalMs: 1, + attemptTimeoutMs: 20, + }), + ).resolves.toEqual({ seeded: false, reason: undefined }); + }); + + it("keeps a cancellation a cancellation", async () => { + // Stopping throws here like anywhere else. Swallowed, it would end the run + // by telling the user to sign in to an install they just stopped. + const controller = new AbortController(); + const session = sessionAnswering( + vi.fn(async (command: string) => { + if (command.includes("RootUserSeeder")) { + controller.abort(); + throw new Error("aborted"); + } + return { code: 0, stdout: transcript("no"), stderr: "" }; + }) as never, + ); + + await expect( + waitForAdminSeeded(session, "me@gmail.com", { + timeoutMs: 20, + intervalMs: 1, + attemptTimeoutMs: 20, + signal: controller.signal, + }), + ).rejects.toMatchObject({ kind: DyadErrorKind.UserCancelled }); + }); + + it("sees the account past a notice printed beside the answer", async () => { + // Required to be the whole answer, one deprecation notice would report a + // seeded account as missing — and the run then fails with Coolify + // refusing to create an account it already created. + const session = sessionAnswering( + vi.fn(async () => ({ + code: 0, + stdout: transcript("PHP Deprecated: something\nyes"), + stderr: "", + })) as never, + ); + + await expect( + waitForAdminSeeded(session, "me@gmail.com", { + timeoutMs: 50, + intervalMs: 1, + attemptTimeoutMs: 50, + }), + ).resolves.toMatchObject({ seeded: true }); + }); + + it("bounds the repair and the confirmation after it, not only the poll", async () => { + // The loop expiring is where the seeder runs, and the question after it + // is the same question — asked on the same server that just failed to + // answer four times. + const asked: Array<{ command: string; timeoutMs?: number }> = []; + const session = sessionAnswering( + vi.fn(async (command: string, options?: { timeoutMs?: number }) => { + asked.push({ command, timeoutMs: options?.timeoutMs }); + return { code: 0, stdout: transcript("no"), stderr: "" }; + }) as never, + ); + + await waitForAdminSeeded(session, "me@gmail.com", { + timeoutMs: 20, + intervalMs: 1, + attemptTimeoutMs: 50, + }); + + // Every question, including the seeder and the confirmation after it. + expect(asked.length).toBeGreaterThan(1); + for (const question of asked) { + expect(question.timeoutMs).toBeGreaterThan(0); + } + }); + + it("asks again when one attempt was merely slow", async () => { + // The bound is ours, not the server's: the link is fine and the poll has + // minutes left. Treating it as a dead link ended the wait on the first + // slow answer, on a server where the account had in fact been seeded. + let asked = 0; + const session = sessionAnswering( + vi.fn(async () => { + asked += 1; + if (asked === 1) { + throw new SshError( + "command-timeout", + "The server did not answer in time.", + DyadErrorKind.External, + ); + } + return { code: 0, stdout: transcript("yes"), stderr: "" }; + }) as never, + ); + + await expect( + waitForAdminSeeded(session, "me@gmail.com", { + timeoutMs: 2_000, + intervalMs: 1, + }), + ).resolves.toEqual({ seeded: true }); + expect(asked).toBeGreaterThan(1); + }); + + it("reports the seeder's words when the last question times out", async () => { + // A bound being hit is not an answer, and it must not become the answer: + // the seeder has already said why it refused, and that is what the user + // needs. Pinned because the bound and the rethrow rule are set in two + // different places and either could stop agreeing with the other. + let asked = 0; + const session = sessionAnswering( + vi.fn(async (command: string) => { + asked += 1; + if (command.includes("db:seed")) { + return { + code: 0, + stdout: "ERROR Invalid Root User Environment Variables\n", + stderr: "", + }; + } + throw new SshError( + "command-timeout", + "The server did not answer in time.", + DyadErrorKind.External, + ); + }) as never, + ); + + const outcome = await waitForAdminSeeded(session, "me@gmail.com", { + timeoutMs: 20, + intervalMs: 1, + attemptTimeoutMs: 5, + }); + + expect(outcome.seeded).toBe(false); + expect(outcome.reason).toContain("Invalid Root User"); + expect(asked).toBeGreaterThan(1); + }); + + it("does not report a dead link as Coolify refusing the address", async () => { + // Waiting longer cannot revive a connection. Swallowed, it becomes a + // complaint about the email address — after polling a dead link for a + // minute and a half and then running the seeder down it as well. + let asked = 0; + const session = sessionAnswering( + vi.fn(async () => { + asked += 1; + throw new SshError( + "timeout", + "The server stopped answering.", + DyadErrorKind.External, + ); + }) as never, + ); + + await expect( + waitForAdminSeeded(session, "me@gmail.com", { + timeoutMs: 2_000, + intervalMs: 1, + }), + ).rejects.toMatchObject({ failure: "timeout" }); + // Once: it gave up on the first answer rather than polling a dead link + // and then asking the seeder down the same one. + expect(asked).toBe(1); + }); +}); diff --git a/src/coolify_setup/install.ts b/src/coolify_setup/install.ts new file mode 100644 index 0000000000..2d8220ba10 --- /dev/null +++ b/src/coolify_setup/install.ts @@ -0,0 +1,462 @@ +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { sleep } from "./sleep"; +import { plainUrlFor } from "./https_setup"; +import { SshError } from "@/ipc/utils/ssh_client"; +import type { SshSession } from "@/ipc/utils/ssh_client"; +import { answerLine, runTinker } from "./tinker"; +import type { AdminCredentials } from "./admin_credentials"; +import { isShellSafe } from "./admin_credentials"; + +/** + * Installing Coolify on a bare server. + * + * The installer is fetched on the server and fed to a shell there, with the + * admin account's details in the environment. Coolify seeds that account itself + * on first start, which is what saves the user a trip to the dashboard. + */ + +/** Coolify's own published installer. */ +const INSTALLER_URL = "https://cdn.coollabs.io/coolify/install.sh"; + +/** Coolify asks for 2GB; below it the install completes and then falls over. */ +const MINIMUM_MEMORY_MB = 1900; + +export interface Preflight { + ready: boolean; + /** Present when ready is false, phrased for the user. */ + reason?: string; + alreadyInstalled: boolean; + /** + * Whether the probe actually settled the Coolify question. + * + * False means the evidence was unusable, not that the server is empty — + * docker wedged, or nothing read back at all. The two are the same + * `alreadyInstalled: false` and must not be treated alike: one of them is + * what stands between a user and installing over an instance that is + * already there, or dropping the only copy of a live admin password. + */ + installedKnown: boolean; + memoryMb: number | null; +} + +/** + * How long a question may take before it is not worth waiting for. + * + * Each of these is short and has an honest answer in seconds. A server whose + * docker has wedged answers none of them at all, and unbounded the step simply + * never ends — the user has to work out that nothing is happening and stop it + * themselves. The installer stays unbounded: it genuinely runs for minutes + * with nothing to say. + */ +const PROBE_TIMEOUT_MS = 30_000; +/** Longer, because the seeder writes to Coolify's own database. */ +const SEEDER_TIMEOUT_MS = 60_000; + +/** + * Looks at the server before touching it. + * + * Checked first because the install takes minutes and every one of these is + * something the user can act on immediately — and because installing over an + * existing Coolify would be far worse than declining to. + */ + +export async function preflight( + session: SshSession, + { signal }: { signal?: AbortSignal } = {}, +): Promise { + const probe = await session.run( + // One round trip rather than several: each answer is a labelled line, so a + // missing one is distinguishable from an empty one. + [ + "echo \"mem=$(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo 2>/dev/null)\"", + // Whether Coolify is actually there, rather than whether a directory + // with its name is. A failed install leaves the directory behind, and + // treating that as an install refuses the retry that would fix it. + `echo "container=$(docker ps -a --filter name=^coolify$ --format '{{.Names}}' 2>/dev/null | head -1)"`, + // Whether the answer above means anything. A docker that is installed + // but not running answers nothing at all, which reads the same as a + // machine with no Coolify on it. + `echo "dockerok=$(if docker info >/dev/null 2>&1; then echo yes; elif command -v docker >/dev/null 2>&1; then echo no; else echo absent; fi)"`, + // A cloud server runs its own updates on first boot and holds the + // package lock while it does. Coolify's installer needs that lock to + // install Docker, fails when it cannot get it, and leaves the server + // half-set-up — so this is worth a second of checking beforehand. + // Asked of the lock rather than of process names: the updater runs as + // python3 with its own name only in the command line, so no name match + // finds it. fuser is not everywhere, so a name check still backs it up. + `echo "busy=$(fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock >/dev/null 2>&1 || pgrep -x 'apt|apt-get|dpkg' >/dev/null 2>&1 && echo yes || echo no)"`, + ].join("; "), + { signal, timeoutMs: PROBE_TIMEOUT_MS }, + ); + + const read = (key: string): string | null => { + const match = new RegExp(`^${key}=(.*)$`, "m").exec(probe.stdout); + return match ? match[1].trim() : null; + }; + + // Every answer below is read out of one round trip, so an empty transcript + // is not a server with nothing on it — it is a question that never got + // asked. Answering it as "ready, no Coolify here" is the one wrong answer + // that matters: it is what stands between the user and installing over an + // instance that is already there. + if (read("mem") === null && read("busy") === null) { + return { + ready: false, + alreadyInstalled: false, + installedKnown: false, + memoryMb: null, + reason: + "Dyad could not read anything back from this server. It answered the " + + "connection but not the question — check it and try again.", + }; + } + + // Docker is there but will not answer, so what it said about Coolify is + // not evidence. Installing over an instance that is merely stopped is the + // outcome worth refusing. + if (read("dockerok") === "no") { + return { + ready: false, + alreadyInstalled: false, + installedKnown: false, + memoryMb: null, + reason: + "Docker is installed on this server but not responding, so Dyad " + + "cannot tell whether Coolify is already on it. Start Docker and try " + + "again.", + }; + } + + const memoryRaw = read("mem"); + const memoryMb = + memoryRaw && /^\d+$/.test(memoryRaw) ? Number(memoryRaw) : null; + const alreadyInstalled = Boolean(read("container")); + + if (read("busy") === "yes") { + return { + ready: false, + alreadyInstalled, + installedKnown: true, + memoryMb, + reason: + "This server is still finishing its own first-boot setup, which holds " + + "the package manager Coolify's installer needs. Wait a minute and " + + "check again.", + }; + } + if (alreadyInstalled) { + return { + ready: false, + alreadyInstalled, + installedKnown: true, + memoryMb, + reason: + "This server already has Coolify on it. Connect to it with an API token " + + "instead of installing again.", + }; + } + // Unreadable, not unlimited. The transcript guard above only fires when + // nothing at all came back, and a server whose /proc/meminfo cannot be read + // answers `mem=` — an empty value, which reaches here as null and would + // walk straight past the check below. Installing then finishes and Coolify + // does not run, on a machine that now refuses a second attempt. + if (memoryMb === null) { + return { + ready: false, + alreadyInstalled, + installedKnown: true, + memoryMb: null, + reason: + "Dyad could not read how much memory this server has, so it cannot " + + "tell whether Coolify would run on it. Check the server and try " + + "again.", + }; + } + if (memoryMb < MINIMUM_MEMORY_MB) { + return { + ready: false, + alreadyInstalled, + installedKnown: true, + memoryMb, + reason: + `This server has ${memoryMb}MB of memory and Coolify needs about 2GB. ` + + `Installing would finish and then fail to run.`, + }; + } + return { ready: true, alreadyInstalled, installedKnown: true, memoryMb }; +} + +/** + * The command that installs Coolify with its admin account seeded. + * + * Values are single-quoted and anything that could end that quoting is refused + * rather than escaped — these run as root on somebody else's machine, and a + * near-miss there is a command injection. + * + * The installer itself is piped to a shell, which is Coolify's documented way + * of running it. + */ +export function buildInstallScript(credentials: AdminCredentials): string { + for (const [label, value] of Object.entries(credentials)) { + if (!isShellSafe(value)) { + throw new DyadError( + `The ${label} contains a character that cannot be sent safely.`, + DyadErrorKind.Validation, + ); + } + } + return ( + [ + // Without it the pipeline reports what the last command did, and a curl + // that downloaded nothing still ends in a bash that exits 0 — so a + // failed download would read as a finished install. + "set -o pipefail", + `export ROOT_USERNAME='${credentials.username}'`, + `export ROOT_USER_EMAIL='${credentials.email}'`, + `export ROOT_USER_PASSWORD='${credentials.password}'`, + 'curl -fsSL "$1" | bash', + ].join("\n") + "\n" + ); +} + +/** + * The command the script above is fed to. + * + * The address is an argument and the credentials are not: a command line is + * readable by every user on the machine through `ps`, while stdin is not. The + * installer's address is public, and having it there says what a long-running + * root command is doing. + */ +export function buildInstallCommand(): string { + return `bash -s -- ${INSTALLER_URL}`; +} + +export async function installCoolify( + session: SshSession, + credentials: AdminCredentials, + { + onOutput, + signal, + }: { onOutput?: (chunk: string) => void; signal?: AbortSignal } = {}, +): Promise { + const result = await session.run(buildInstallCommand(), { + input: buildInstallScript(credentials), + onOutput, + signal, + }); + if (result.code !== 0) { + // The installer's own last words, rather than only its exit code. Its + // most common failure on a new cloud server is losing a race for the + // package lock against the server's own first-boot updates, and that is + // only visible in what it printed. + const tail = `${result.stdout}\n${result.stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .slice(-3) + .join(" "); + throw new DyadError( + `Installing Coolify failed (exit ${result.code}).` + + (tail ? ` The server said: ${tail}` : ""), + DyadErrorKind.External, + ); + } +} + +/** + * Waits for the dashboard to answer. + * + * The installer returns before Coolify is listening, so this is what stands + * between finishing the install and using it. Any answer counts — a redirect to + * the login page is the dashboard working, not a failure. + */ +export async function waitForDashboard( + host: string, + { + timeoutMs = 5 * 60 * 1000, + intervalMs = 5_000, + signal, + now = () => Date.now(), + fetchImpl = fetch, + }: { + timeoutMs?: number; + intervalMs?: number; + signal?: AbortSignal; + now?: () => number; + fetchImpl?: typeof fetch; + } = {}, +): Promise { + const deadline = now() + timeoutMs; + while (now() < deadline) { + if (signal?.aborted) { + throw new DyadError("Cancelled.", DyadErrorKind.UserCancelled); + } + try { + const res = await fetchImpl(plainUrlFor(host), { + method: "GET", + redirect: "manual", + signal: AbortSignal.timeout(5_000), + }); + if (res.status > 0) return true; + } catch { + // Not listening yet, which is the expected state for the first minute. + } + await sleep(intervalMs, signal); + } + return false; +} + +/** + * Whether Coolify has the account yet. + * + * A question about right now, not a verdict: the account is created by a + * startup service, and the dashboard starts answering before that service has + * run. Asked once, immediately, this says "no" about a server that is merely + * still starting. + * + * WORKAROUND: nothing exposes whether the first account exists, so this asks + * the model. + * + * TODO: replace if Coolify gains a way to ask about, or create, the first user. + */ +export async function isAdminSeeded( + session: SshSession, + email: string, + { signal, timeoutMs }: { signal?: AbortSignal; timeoutMs?: number } = {}, +): Promise { + try { + const output = await runTinker( + session, + `echo \\App\\Models\\User::where('email', getenv('DYAD_ADMIN_EMAIL'))->exists() ? 'yes' : 'no';`, + { env: { DYAD_ADMIN_EMAIL: email }, signal, timeoutMs }, + ); + // One line of the answer: a notice printed beside it must not read as + // the account not being there. + return Boolean(answerLine(output, (line) => line === "yes")); + } catch (error) { + // A container still starting cannot answer at all. That is not the same as + // answering no, and treating it as one is how a healthy server gets + // reported as a rejected email address. + const kind = (error as { kind?: string }).kind; + if (kind === "user_cancelled") throw error; + // A connection that has died is not a container still starting: waiting + // longer cannot help, and swallowing it reports a lost link as Coolify + // refusing the address. A bound we imposed ourselves is the opposite — + // the link is fine and the question is worth asking again, which is the + // whole reason the caller is polling. + if (error instanceof SshError && error.failure !== "command-timeout") { + throw error; + } + return false; + } +} + +/** + * Runs Coolify's own seeder and returns what it said. + * + * Used only after waiting has not produced an account. It is both the repair — + * if the startup service somehow did not run, this runs it — and the + * diagnosis, because the seeder names the reason it refuses rather than + * leaving us to guess at one. + * + * WORKAROUND: an internal seeder is the only thing that creates the first + * user, so it is invoked by name. + * + * TODO: replace if Coolify gains a supported way to create the first account. + */ +export async function runAdminSeeder( + session: SshSession, + { signal }: { signal?: AbortSignal } = {}, +): Promise { + const result = await session.run( + "docker exec -i coolify php artisan db:seed --class=RootUserSeeder --no-ansi --force", + { signal, timeoutMs: SEEDER_TIMEOUT_MS }, + ); + return `${result.stdout}\n${result.stderr}`.trim(); +} + +export interface AdminSeedOutcome { + seeded: boolean; + /** The seeder's own words, when it had something to say about refusing. */ + reason?: string; +} + +/** + * Waits for the admin account, repairing and diagnosing if it never appears. + * + * The account arrives a little after the dashboard does, so this polls rather + * than deciding on the first answer. If waiting is not enough it runs the + * seeder directly: that fixes the case where the startup service did not run, + * and where the address is genuinely refused it produces the reason instead of + * an accusation invented here. + */ +export async function waitForAdminSeeded( + session: SshSession, + email: string, + { + timeoutMs = 90_000, + intervalMs = 5_000, + attemptTimeoutMs = 20_000, + signal, + now = () => Date.now(), + }: { + timeoutMs?: number; + intervalMs?: number; + /** One question, bounded — the loop's deadline cannot bound it. */ + attemptTimeoutMs?: number; + signal?: AbortSignal; + now?: () => number; + } = {}, +): Promise { + const deadline = now() + timeoutMs; + while (now() < deadline) { + // Each attempt is bounded, not only the loop: asking goes over SSH, and a + // wedged docker daemon answers nothing at all, so an unbounded attempt + // outlasts the deadline it is supposed to be inside. Bounded in the + // command rather than raced beside it, so giving up on the answer also + // stops the question. + const answered = await isAdminSeeded(session, email, { + signal, + timeoutMs: attemptTimeoutMs, + }); + if (answered) return { seeded: true }; + await sleep(intervalMs, signal); + } + + let output = ""; + try { + output = await runAdminSeeder(session, { signal }); + } catch { + // Cancelling stops this the way it stops everything else, and that is not + // a server that would not seed. It has to stay a cancellation, or the run + // ends by telling the user to go and sign in to an install they stopped. + if (signal?.aborted) { + throw new DyadError("Cancelled.", DyadErrorKind.UserCancelled); + } + // Anything else leaves the install standing. Coolify is on the server, and + // a seeder that timed out or died says nothing either way about whether + // the account exists — so it is asked below rather than assumed, and what + // comes back is a server to sign in to by hand rather than a failed run. + // Ending here instead would take the password down with it. + } + // Asked once more, bounded like every other question. A bound that is hit + // reads as "no account yet" rather than as a failure — isAdminSeeded already + // answers that way — so the seeder's own words, which say why it refused, + // are what gets reported below. + if ( + await isAdminSeeded(session, email, { signal, timeoutMs: attemptTimeoutMs }) + ) { + return { seeded: true }; + } + + // Coolify prints its complaint as "ERROR Invalid Root User Environment + // Variables" followed by the field it objected to. Handing that back beats + // naming a cause we only assumed. + const complaint = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith("→") || /invalid|error/i.test(line)) + .join(" ") + .trim(); + return { seeded: false, reason: complaint || undefined }; +} diff --git a/src/coolify_setup/server_key.test.ts b/src/coolify_setup/server_key.test.ts new file mode 100644 index 0000000000..4b93383f57 --- /dev/null +++ b/src/coolify_setup/server_key.test.ts @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ userData: "" })); + +vi.mock("@/paths/paths", () => ({ getUserDataPath: () => h.userData })); + +const { ensureServerKey, serverKeyPath } = await import("./server_key"); + +describe("the key the user installs on their server", () => { + beforeEach(() => { + h.userData = fs.mkdtempSync(path.join(os.tmpdir(), "dyad-server-key-")); + }); + + afterEach(() => { + fs.rmSync(h.userData, { recursive: true, force: true }); + }); + + it("refuses a private half that cannot be read, whatever the .pub says", () => { + // The stored public half is not evidence about the private one. Handing + // it back for a key that cannot be parsed gives the user a line to + // install that nothing can connect with. + ensureServerKey(); + fs.writeFileSync( + serverKeyPath(), + "-----BEGIN OPENSSH PRIVATE KEY-----\nbroken\n-----END OPENSSH PRIVATE KEY-----\n", + ); + + expect(() => ensureServerKey()).toThrow(); + }); + + it("ignores a stored public half that belongs to another key", () => { + // The .pub is kept for its comment, not taken as evidence. A line for a + // different key is one the server would accept from someone else. + const derived = ensureServerKey().publicKey; + fs.writeFileSync( + `${serverKeyPath()}.pub`, + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAISOMEOTHERKEYENTIRELYAAAAAAAAAAAA someone-else\n", + ); + + // The key itself, not the comment: a rejected line falls back to the + // derived one, which names itself differently. + const blob = (line: string) => line.split(" ")[1]; + expect(blob(ensureServerKey().publicKey)).toBe(blob(derived)); + }); + + it("hands back one key, whatever else the file has collected", () => { + // What this returns is pasted into a server's authorized_keys. A second + // line riding along would install a key nobody checked. + const derived = ensureServerKey().publicKey; + fs.appendFileSync( + `${serverKeyPath()}.pub`, + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAISOMEOTHERKEYENTIRELYAAAAAAAAAAAA someone-else\n", + ); + + const shown = ensureServerKey().publicKey; + expect(shown.trim().split("\n")).toHaveLength(1); + expect(shown).toBe(derived); + }); + + it("ignores a stored line that claims a different key type", () => { + // The key is ed25519. A line naming it as something else describes a key + // no server would accept it as. + const blob = ensureServerKey().publicKey.split(" ")[1]; + fs.writeFileSync(`${serverKeyPath()}.pub`, `ssh-rsa ${blob} borrowed\n`); + + expect(ensureServerKey().publicKey.startsWith("ssh-ed25519 ")).toBe(true); + }); + + it("shows the same line every time it is asked", () => { + // The user pastes this into their server once. A different line on the + // next launch reads as a different key. + const first = ensureServerKey().publicKey; + const second = ensureServerKey().publicKey; + + expect(second).toBe(first); + }); +}); diff --git a/src/coolify_setup/server_key.ts b/src/coolify_setup/server_key.ts new file mode 100644 index 0000000000..87d822e110 --- /dev/null +++ b/src/coolify_setup/server_key.ts @@ -0,0 +1,100 @@ +import * as fs from "fs"; +import * as path from "path"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { getUserDataPath } from "@/paths/paths"; +import { + generateDeployKeyPair, + publicKeyFromPrivate, +} from "@/ipc/utils/coolify_deploy_key"; + +/** + * The key Dyad uses to reach a server it is setting up. + * + * Separate from the deploy keys, which are per repository and handed to GitHub + * and Coolify. This one is Dyad's own identity for logging into a machine, so + * there is one of it, it is never uploaded anywhere, and it outlives any single + * server: the user adds its public half once and can reuse it for the next + * server they set up. + * + * Kept out of ~/.ssh deliberately, for the reason the deploy keys are: that + * directory holds identities the user maintains by hand, and Dyad treats it as + * off-limits everywhere else. + */ + +const KEY_NAME = "server_access"; +const KEY_COMMENT = "dyad-server-access"; + +export function serverKeyDirPath(): string { + return path.join(getUserDataPath(), "coolify_server_key"); +} + +export function serverKeyPath(): string { + return path.join(serverKeyDirPath(), KEY_NAME); +} + +export interface ServerKey { + /** What the user adds to the server's authorized_keys. */ + publicKey: string; + /** OpenSSH format, which is what the wire library accepts. */ + privateKey: string; +} + +/** + * The stored line when it names the key on disk, and the derived one when not. + * + * Both halves matter. Deriving alone renames the key — the comment comes from + * whichever constant did the deriving — and the panel would show a different + * line each launch than the one already installed on the server. Trusting the + * stored file alone would hand back a public half belonging to some other key. + */ +function storedMatching(keyPath: string, derived: string): string { + try { + // The first line only, and matched on both fields. What comes back is + // pasted into a server's authorized_keys, so anything further down the + // file would be installed alongside the key it was checked for. + const stored = fs + .readFileSync(`${keyPath}.pub`, "utf8") + .split("\n")[0] + .trim(); + const [storedType, storedKey] = stored.split(/\s+/); + const [derivedType, derivedKey] = derived.split(/\s+/); + const sameKey = storedType === derivedType && storedKey === derivedKey; + return sameKey ? `${stored}\n` : derived; + } catch { + return derived; + } +} + +/** + * Returns Dyad's server key, creating it the first time. + * + * Reused rather than regenerated per server: the public half is something the + * user pastes into a console by hand, and making them do that again for every + * server would be the most tedious part of the whole flow. + */ +export function ensureServerKey(): ServerKey { + const keyPath = serverKeyPath(); + if (fs.existsSync(keyPath)) { + const privateKey = fs.readFileSync(keyPath, "utf8"); + const derived = publicKeyFromPrivate(privateKey); + if (derived) { + return { privateKey, publicKey: storedMatching(keyPath, derived) }; + } + // A file that cannot be read as a key is worse than none: it would fail at + // connect time with something about the wire format. Say so here instead. + throw new DyadError( + `The server key at ${keyPath} could not be read. Delete it and Dyad will ` + + `generate a new one — you will need to add the new public key to your ` + + `server.`, + DyadErrorKind.Precondition, + ); + } + + const pair = generateDeployKeyPair(KEY_COMMENT); + fs.mkdirSync(serverKeyDirPath(), { recursive: true, mode: 0o700 }); + // 0600 is a no-op on Windows, where the directory's own permissions are what + // protects this. Set anyway, because it is what protects it everywhere else. + fs.writeFileSync(keyPath, pair.privateKey, { mode: 0o600 }); + fs.writeFileSync(`${keyPath}.pub`, pair.publicKey, { mode: 0o644 }); + return { privateKey: pair.privateKey, publicKey: pair.publicKey }; +} diff --git a/src/coolify_setup/setup_flow.integration.test.ts b/src/coolify_setup/setup_flow.integration.test.ts new file mode 100644 index 0000000000..adefa40a2f --- /dev/null +++ b/src/coolify_setup/setup_flow.integration.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { runServerSetup } from "./setup_flow"; +import { connectSsh, trustOnFirstUse } from "@/ipc/utils/ssh_client"; +import type { SshSession } from "@/ipc/utils/ssh_client"; +import { + generateSshKeyPair, + startFakeSshServer, + type FakeSshServer, +} from "@/testing/fake_ssh_server"; + +/** + * The setup flow over a real SSH connection, without the app around it. + * + * Dyad's own SSH client talks to a real ssh2 server here, so preflight, the + * installer and the tinker transcripts these tests reach are parsed from the + * shapes the library actually produces rather than ones a hand-written fake + * finds convenient. + * + * Only the two HTTP steps are stubbed: whether a dashboard answers and + * whether a certificate is issued are not questions a server on loopback can + * be asked, and neither goes over SSH. + * + * These live here rather than in e2e because none of them looks at the + * screen. The packaged app proves the wiring — that the handler is registered + * and the panel renders what it returns — and that is what the two remaining + * Playwright specs are for. + */ + +const KEY = generateSshKeyPair().private; + +let server: FakeSshServer | undefined; + +beforeEach(async () => { + server = await startFakeSshServer(); +}); + +afterEach(async () => { + // Optional: a server that failed to start leaves nothing to close, and + // throwing here would bury the reason it failed under a second error. + await server?.close(); + server = undefined; +}); + +function runSetup() { + const output: string[] = []; + const done = runServerSetup({ + onProgress: ({ output: chunk }) => { + if (chunk) output.push(chunk); + }, + target: { + host: "127.0.0.1", + port: server!.port, + username: "root", + privateKey: KEY, + }, + adminEmail: "me@gmail.com", + verifyHostKey: trustOnFirstUse(() => {}), + connect: (target, verify, signal): Promise => + connectSsh(target, verify, { signal }), + // The dashboard is a URL, not a server: there is nothing on loopback to + // answer it, and answering it is not what these tests are about. + waitForDashboardImpl: async () => true, + tryEnableHttpsImpl: async (_session, host) => ({ + instanceUrl: `http://${host}:8000`, + secure: false, + reason: "A loopback address cannot be given a certificate.", + }), + }); + return { done, output }; +} + +describe("a server that refuses the install", () => { + it("reports what the installer said, not only that it failed", async () => { + // The installer's most common failure is losing a race for the package + // lock, and an exit code alone says nothing about that. Its last words do. + server!.state.installExit = 1; + const { done, output } = runSetup(); + + await expect(done).rejects.toThrow(/Coolify is up/); + // And through the stream, which is a different path: the panel's log is + // built from what arrives here, not from the message thrown at the end. + expect(output.join("")).toContain("Coolify is up"); + }); +}); + +describe("a Coolify too old to make its own token", () => { + it("finishes anyway and hands back the sign-in details", async () => { + // The server is installed and usable; only the token has to be made by + // hand. Throwing here would discard a working install. + server!.state.version = "3.1.0"; + + const result = await runSetup().done; + + expect(result.token).toBeNull(); + expect(result.tokenUnavailableReason).toBeTruthy(); + expect(result.credentials.email).toBe("me@gmail.com"); + expect(result.credentials.password).not.toBe(""); + }); +}); + +describe("a server that reports its exit status late", () => { + it("does not read the end of the output as the end of the command", async () => { + // A real sshd closes a command's output when its stdout closes and reports + // the status when the process is reaped. Closing the channel on the first + // of those loses the status — a finished install read as a failure, and + // against a real server a command killed while it was still running. + server!.state.exitAfterEofMs = 50; + + const result = await runSetup().done; + + expect(result.token).toBeTruthy(); + expect(result.version).toBeTruthy(); + }); +}); diff --git a/src/coolify_setup/setup_flow.test.ts b/src/coolify_setup/setup_flow.test.ts new file mode 100644 index 0000000000..47ff093d42 --- /dev/null +++ b/src/coolify_setup/setup_flow.test.ts @@ -0,0 +1,780 @@ +import { describe, expect, it, vi } from "vitest"; +import { runServerSetup, type SetupStep } from "./setup_flow"; +import { waitForAdminSeeded } from "./install"; +import { tryEnableHttps } from "./https_setup"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { SshError } from "@/ipc/utils/ssh_client"; +import type { SshSession } from "@/ipc/utils/ssh_client"; + +const REAL_TOKEN = "1|EcaUxT43T5fgdLJmnYj0702tEUC6viy5jEhO3Ujk2298db95"; + +function transcript(output: string): string { + return [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "> __DYAD_OUT_START__", + output, + "__DYAD_OUT_END__", + ].join("\n"); +} + +/** + * A server that answers each command by what the command is for. + * + * Matching on the command rather than on call order means a test that changes + * the number of steps does not silently start answering the wrong question. + */ +function fakeServer( + overrides: { + probe?: string; + probeAfterInstall?: string; + installExit?: number; + seeded?: string; + seederOutput?: string; + httpsWorks?: boolean; + version?: string; + apiEnabled?: string; + token?: string; + } = {}, +) { + const commands: string[] = []; + const scripts: string[] = []; + let probes = 0; + const session: SshSession = { + run: vi.fn(async (command: string, options?: { input?: string }) => { + commands.push(command); + // Tinker scripts travel on stdin, so what is being asked is in the + // input rather than in the command — which is the whole point of + // piping them. + const script = options?.input ?? ""; + scripts.push(script); + if (command.includes("MemTotal")) { + probes += 1; + const after = overrides.probeAfterInstall; + return { + code: 0, + stdout: + (probes > 1 && after ? after : overrides.probe) ?? + "os=ubuntu\nmem=1967\ndir=no\ncontainer=\nbusy=no\narch=x86_64", + stderr: "", + }; + } + if (command.includes("install.sh")) { + return { + code: overrides.installExit ?? 0, + stdout: "installed", + stderr: "", + }; + } + if (script.includes("constants.coolify.version")) { + return { + code: 0, + stdout: transcript(overrides.version ?? "4.3.2"), + stderr: "", + }; + } + if (script.includes("->exists()")) { + return { + code: 0, + stdout: transcript(overrides.seeded ?? "yes"), + stderr: "", + }; + } + if (command.includes("RootUserSeeder")) { + return { + code: 0, + stdout: + overrides.seederOutput ?? + " ERROR Invalid Root User Environment Variables\n \u2192 The email field must be a valid email address.", + stderr: "", + }; + } + if (script.includes("createToken")) { + return { + code: 0, + stdout: transcript(overrides.token ?? REAL_TOKEN), + stderr: "", + }; + } + if (script.includes("setupDynamicProxyConfiguration")) { + return { code: 0, stdout: transcript("applied"), stderr: "" }; + } + if (script.includes("is_api_enabled")) { + return { + code: 0, + stdout: transcript(overrides.apiEnabled ?? "enabled"), + stderr: "", + }; + } + return { code: 0, stdout: "", stderr: "" }; + }) as unknown as SshSession["run"], + end: vi.fn(), + }; + return { session, commands, scripts, httpsWorks: overrides.httpsWorks }; +} + +function run( + server: ReturnType, + extra: Partial[0]> = {}, +) { + const steps: SetupStep[] = []; + return { + steps, + promise: runServerSetup({ + target: { host: "203.0.113.5", username: "root", privateKey: "KEY" }, + adminEmail: "admin@gmail.com", + verifyHostKey: () => true, + connect: async () => server.session, + waitForDashboardImpl: async () => true, + // The real waiter, wound right down: what matters is that it polls, and + // five-second sleeps would only make the suite slow. + // The real HTTPS logic, wound down. What matters is that it asks, checks, + // and reverts — five-second polls would only make the suite slow. + tryEnableHttpsImpl: (session, host, options) => + tryEnableHttps(session, host, { + ...options, + timeoutMs: 40, + intervalMs: 5, + check: async () => server.httpsWorks !== false, + }), + waitForAdminSeededImpl: (session, email, options) => + waitForAdminSeeded(session, email, { + ...options, + timeoutMs: 40, + intervalMs: 5, + }), + onProgress: ({ step }) => { + if (steps[steps.length - 1] !== step) steps.push(step); + }, + ...extra, + }), + }; +} + +describe("runServerSetup", () => { + it("takes a bare server all the way to a token", async () => { + const server = fakeServer(); + const { promise, steps } = run(server); + const result = await promise; + + expect(result.token).toBe(REAL_TOKEN); + expect(result.version).toBe("4.3.2"); + // HTTPS by default: the token carries root abilities and travels on every + // deploy, so the address it travels to is worth a certificate. + expect(result.dashboardUrl).toBe("https://203.0.113.5.sslip.io"); + expect(result.secure).toBe(true); + expect(result.credentials.email).toBe("admin@gmail.com"); + expect(steps).toEqual([ + "connecting", + "checking-server", + "installing", + "waiting-for-dashboard", + "verifying-account", + "securing", + "creating-token", + "done", + ]); + }); + + it("looks at the server before installing anything", async () => { + // The two things a user can fix immediately cost a second to find, and the + // install costs minutes. Finding them afterwards wastes both. + const server = fakeServer({ + probe: "os=ubuntu\nmem=1967\ndir=yes\ncontainer=coolify\nbusy=no", + }); + await expect(run(server).promise).rejects.toMatchObject({ + kind: "precondition", + }); + expect(server.commands.some((c) => c.includes("install.sh"))).toBe(false); + }); + + it("waits rather than racing the server's own first-boot updates", async () => { + // A new cloud server holds the package lock while it updates itself, and + // Coolify's installer needs that lock for Docker. Losing that race leaves + // the server half set up, which is worse than a second of checking. + const server = fakeServer({ + probe: "os=ubuntu\nmem=1967\ndir=no\ncontainer=\nbusy=yes", + }); + await expect(run(server).promise).rejects.toThrow(/first-boot setup/); + expect(server.commands.some((c) => c.includes("install.sh"))).toBe(false); + }); + + it("allows a retry after an install that did not finish", async () => { + // A failed install leaves /data/coolify behind with nothing running. + // Reading that as "already installed" refuses the retry that would fix it. + const server = fakeServer({ + probe: "os=ubuntu\nmem=1967\ndir=yes\ncontainer=\nbusy=no", + }); + await expect(run(server).promise).resolves.toBeTruthy(); + }); + + it("refuses a server too small to run what it would install", async () => { + const server = fakeServer({ + probe: "os=ubuntu\nmem=980\ndir=no\ncontainer=\nbusy=no", + }); + await expect(run(server).promise).rejects.toMatchObject({ + kind: "precondition", + }); + expect(server.commands.some((c) => c.includes("install.sh"))).toBe(false); + }); + + it("keeps the install when only the token could not be created", async () => { + // The server is set up and usable; throwing here would discard that over + // the one step the user can complete by hand. + const server = fakeServer({ version: "3.1.0" }); + const result = await run(server).promise; + + expect(result.token).toBeNull(); + expect(result.tokenUnavailableReason).toBeTruthy(); + expect(result.dashboardUrl).toBe("https://203.0.113.5.sslip.io"); + expect(result.credentials.password).toBeTruthy(); + // Too old to set up automatically, so nothing was switched on and + // switching it on is still the user's to do. + expect(result.apiEnabled).toBe(false); + }); + + it("does not call an unreadable version an unsupported one", async () => { + // The whole install fetches the newest Coolify, so the version being too + // old is the least likely reason the token step did not finish. Reported + // that way, the user goes looking for a problem with a server that is + // minutes old. + const server = fakeServer({ version: "Command not found" }); + const result = await run(server).promise; + + expect(result.token).toBeNull(); + expect(result.tokenUnavailableReason).toMatch( + /could not read which version/, + ); + expect(result.tokenUnavailableReason).not.toMatch( + /version of Coolify could not be set up/, + ); + // The install still stands, and the way on is on the screen. + expect(result.credentials.password).toBeTruthy(); + }); + + it("does not tell the user a fresh install is too old when it was only slow", async () => { + // A tinker one-liner over the 30s bound on a 2GB box right after an + // install is ordinary. Reported as an unsupported version, the user is + // told something false about a Coolify they installed minutes ago, and + // preflight refuses to install again — so there is nothing to act on. + const server = fakeServer(); + const answering = server.session.run as unknown as ( + command: string, + options?: { input?: string }, + ) => Promise; + server.session.run = (async ( + command: string, + options?: { input?: string }, + ) => { + if ((options?.input ?? "").includes("constants.coolify.version")) { + throw new SshError( + "command-timeout", + "timed out", + DyadErrorKind.External, + ); + } + return answering(command, options); + }) as unknown as SshSession["run"]; + + const result = await run(server).promise; + + expect(result.token).toBeNull(); + expect(result.tokenUnavailableReason).toMatch(/did not answer in time/); + expect(result.tokenUnavailableReason).not.toMatch(/version of Coolify/); + // The install still stands, and the address is still usable. + expect(result.credentials.password).toBeTruthy(); + }); + + it("does not claim the API was opened when opening it is what failed", async () => { + // Reported once the server has confirmed it, not when the attempt + // starts: saying it is on when it is not sends the user past the one + // step they still have to do by hand. + const server = fakeServer({ apiEnabled: "still-disabled" }); + const result = await run(server).promise; + + expect(result.token).toBeNull(); + expect(result.apiEnabled).toBe(false); + }); + + it("names the token step when the link dies making one", async () => { + // The API is already on by then, and the panel no longer tells the user + // to enable it — so blaming the API step would state a cause its own + // remedy contradicts. + const server = fakeServer(); + const original = server.session.run as unknown as ( + command: string, + options?: { input?: string }, + ) => Promise; + server.session.run = (async ( + command: string, + options?: { input?: string }, + ) => { + if ((options?.input ?? "").includes("createToken")) { + throw new SshError( + "timeout", + "the connection stopped answering", + DyadErrorKind.External, + ); + } + return original(command, options); + }) as unknown as SshSession["run"]; + + const result = await run(server).promise; + + expect(result.apiEnabled).toBe(true); + expect(result.tokenUnavailableReason).toBe( + "Coolify stopped answering while Dyad was making a token.", + ); + }); + + it("remembers the API was opened even when the token step then failed", async () => { + // Opening the API and minting a token are two steps in that order, so a + // mint that fails leaves the first done. Reporting otherwise sends the + // user to turn on something that is already on. + const server = fakeServer({ token: "" }); + const result = await run(server).promise; + + expect(result.token).toBeNull(); + expect(result.tokenUnavailableReason).toBeTruthy(); + expect(result.apiEnabled).toBe(true); + }); + + it("keeps the install when HTTPS cannot even be attempted", async () => { + // The server is installed and running by this point. HTTPS improves it; + // failing to get it must not be able to throw it away. + const server = fakeServer(); + const result = await run(server, { + tryEnableHttpsImpl: async () => { + throw new Error("proxy would not restart"); + }, + }).promise; + + expect(result.secure).toBe(false); + expect(result.dashboardUrl).toBe("http://203.0.113.5:8000"); + expect(result.insecureReason).toContain("proxy would not restart"); + expect(result.credentials.password).toBeTruthy(); + }); + + it("carries what a failed HTTPS attempt left behind onto the screen", async () => { + // The run goes on and succeeds from here, so the failed state that would + // otherwise carry this is never reached — the finished screen is the only + // place left to say a domain is still pointing at the server. + const server = fakeServer(); + const result = await run(server, { + tryEnableHttpsImpl: async () => { + throw Object.assign(new Error("proxy would not restart"), { + warning: "Coolify may still be configured for x.sslip.io.", + }); + }, + }).promise; + + expect(result.secure).toBe(false); + expect(result.insecureReason).toContain("proxy would not restart"); + expect(result.insecureReason).toContain("may still be configured"); + // Two sentences. The library's message may or may not end in a stop of + // its own, and neither spelling should run the two together. + expect(result.insecureReason).toContain("restart. Coolify"); + }); + + it("does not leave a stop stranded after the message's own punctuation", async () => { + // Coolify's refusals end in a colon before whatever it printed, and an + // empty answer leaves that colon last. + const server = fakeServer(); + const result = await run(server, { + tryEnableHttpsImpl: async () => { + throw Object.assign(new Error("Coolify did not apply the domain:"), { + warning: "Coolify may still be configured for x.sslip.io.", + }); + }, + }).promise; + + expect(result.insecureReason).toContain("domain. Coolify"); + expect(result.insecureReason).not.toContain(":."); + }); + + it("still stops when the user cancels during HTTPS", async () => { + // Cancelling is the user asking for the work to stop, which is not the + // same as a step that could not be done. + const server = fakeServer(); + await expect( + run(server, { + tryEnableHttpsImpl: async () => { + throw Object.assign(new Error("Cancelled."), { + kind: "user_cancelled", + }); + }, + }).promise, + ).rejects.toMatchObject({ kind: "user_cancelled" }); + }); + + it("gives up on a server that stops answering after a failed install", async () => { + // A frozen machine leaves the connection half-open: the question never + // comes back and nothing else times it out. Unbounded, the run never + // settles and the one-at-a-time slot is never freed, so no later setup + // can start at all. + const server = fakeServer({ installExit: 1 }); + const original = server.session.run as unknown as ( + command: string, + options?: unknown, + ) => Promise; + let installed = false; + server.session.run = (async (command: string, options?: unknown) => { + if (command.includes("install.sh")) { + installed = true; + return original(command, options); + } + // Every probe after the install goes unanswered. + if (installed) return new Promise(() => {}); + return original(command, options); + }) as unknown as SshSession["run"]; + + const seen: string[] = []; + await expect( + run(server, { + recoveryProbeTimeoutMs: 20, + onAccountKnown: ({ credentials }) => seen.push(credentials.password), + }).promise, + ).rejects.toThrow(/Installing Coolify failed/); + // A question that never came back is not an answer of "nothing was + // installed". install.sh may already have written this password into + // Coolify's .env, and dropping the only copy of it cannot be undone from + // here — preflight refuses to install over the container again. + expect(seen).toHaveLength(1); + }); + + it("hands the account over when the probe cannot tell either way", async () => { + // Docker is there but will not answer, so what it said about Coolify is + // not evidence. preflight reports that as the same "no Coolify here" as + // an empty server, and only one of those means the password is dead. + const server = fakeServer({ + installExit: 1, + probeAfterInstall: "os=ubuntu\nmem=1967\ndockerok=no\nbusy=no", + }); + const seen: string[] = []; + await expect( + run(server, { + onAccountKnown: ({ credentials }) => seen.push(credentials.password), + }).promise, + ).rejects.toThrow(/Installing Coolify failed/); + + expect(seen).toHaveLength(1); + }); + + it("hands the account over when a failed install still left Coolify there", async () => { + // install.sh writes the password into Coolify's own .env and brings the + // stack up partway through. Failing after that leaves an account nobody + // else knows the password for, and preflight refuses to install again. + const server = fakeServer({ + installExit: 1, + probeAfterInstall: "os=ubuntu\nmem=1967\ncontainer=coolify\nbusy=no", + }); + const seen: string[] = []; + await expect( + run(server, { + onAccountKnown: ({ credentials }) => seen.push(credentials.password), + }).promise, + ).rejects.toThrow(); + + expect(seen).toHaveLength(1); + }); + + it("keeps a failed install from overwriting another server's password", async () => { + // The common install failure is losing the package lock, which happens + // before install.sh writes anything. Handing the account over at that + // point would replace the credentials for a server that does exist with + // ones for a server that does not. + const server = fakeServer({ installExit: 1 }); + const seen: string[] = []; + await expect( + run(server, { + onAccountKnown: ({ credentials }) => seen.push(credentials.password), + }).promise, + ).rejects.toThrow(); + + expect(seen).toHaveLength(0); + }); + + it("hands over the account even when the dashboard never answers", async () => { + // The poll runs on the user's side of their firewall; the account is + // created by the server from the .env the installer already wrote. A + // closed port 8000 says nothing about whether the account exists. + const server = fakeServer(); + const seen: string[] = []; + await expect( + run(server, { + onAccountKnown: ({ credentials }) => seen.push(credentials.password), + waitForDashboardImpl: async () => false, + }).promise, + ).rejects.toThrow(/nothing answered on port 8000/); + + expect(seen).toHaveLength(1); + expect(seen[0]).toBeTruthy(); + }); + + it("hands over the account as soon as it exists, not at the end", async () => { + // Everything after this point can fail on a server that is installed and + // running. Dyad invented this password and never showed it, so a caller + // that only learns it on success cannot store what it never received. + const server = fakeServer(); + const seen: Array<{ password: string; dashboardUrl: string }> = []; + await expect( + run(server, { + onAccountKnown: ({ credentials, dashboardUrl }) => + seen.push({ password: credentials.password, dashboardUrl }), + tryEnableHttpsImpl: async () => { + throw Object.assign(new Error("Cancelled."), { + kind: "user_cancelled", + }); + }, + }).promise, + ).rejects.toMatchObject({ kind: "user_cancelled" }); + + expect(seen).toHaveLength(1); + expect(seen[0].password).toBeTruthy(); + expect(seen[0].dashboardUrl).toBe("http://203.0.113.5:8000"); + }); + + it("says so again once the address settles on HTTPS", async () => { + // The address is part of signing in, and it is not known until the + // certificate is. Left at the first answer, a later save would read as a + // different Coolify and drop the account. + const server = fakeServer(); + const seen: string[] = []; + await run(server, { + onAccountKnown: ({ dashboardUrl }) => seen.push(dashboardUrl), + }).promise; + + expect(seen).toEqual([ + "http://203.0.113.5:8000", + "https://203.0.113.5.sslip.io", + ]); + }); + + it("keeps the install when enabling the API fails outright", async () => { + const server = fakeServer({ apiEnabled: "still-disabled" }); + const result = await run(server).promise; + + expect(result.token).toBeNull(); + expect(result.tokenUnavailableReason).toContain("API"); + }); + + it("reports the seeder's own reason rather than inventing one", async () => { + // The account never appears, so the seeder is run directly and what it + // says is handed back. Guessing here is how a working gmail address got + // reported as a domain that does not resolve. + const server = fakeServer({ seeded: "no" }); + await expect(run(server).promise).rejects.toThrow( + /must be a valid email address/, + ); + }); + + it("says where to finish when the account could not be seeded", async () => { + // Coolify is on the machine either way, and preflight refuses to install + // over it — so what it objected to is only half of what the user needs. + // The other half was only ever said when there was nothing to report. + const server = fakeServer({ seeded: "no" }); + await expect(run(server).promise).rejects.toThrow( + /The server is installed — open http:\/\/203\.0\.113\.5:8000/, + ); + }); + + it("waits for an account that has not been created yet", async () => { + // The dashboard answers before the startup service that seeds the account + // has run, so the first answer is no on a perfectly healthy server. + let asked = 0; + const server = fakeServer(); + const inner = server.session.run as unknown as ( + c: string, + o?: { input?: string }, + ) => Promise; + server.session.run = (async (c: string, o?: { input?: string }) => { + if ((o?.input ?? "").includes("->exists()")) { + asked += 1; + return { + code: 0, + stdout: transcript(asked === 1 ? "no" : "yes"), + stderr: "", + }; + } + return inner(c, o); + }) as unknown as SshSession["run"]; + + await expect(run(server).promise).resolves.toBeTruthy(); + expect(asked).toBeGreaterThan(1); + }); + + it("falls back to plain HTTP when no certificate arrives", async () => { + // Certificates depend on a third party that can refuse. A server nobody + // can open would be worse than one that is merely unencrypted, so the + // domain is taken back off and the address returns to port 8000. + const server = fakeServer({ httpsWorks: false }); + const result = await run(server).promise; + + expect(result.secure).toBe(false); + expect(result.dashboardUrl).toBe("http://203.0.113.5:8000"); + expect(result.insecureReason).toBeTruthy(); + // The revert itself, not just that some tinker ran: reading the version + // and minting a token are tinkers too, and they run in this test either + // way. + expect(server.scripts.some((t) => t.includes("fqdn = null"))).toBe(true); + }); + + it("stops when the dashboard never answers", async () => { + const server = fakeServer(); + await expect( + run(server, { waitForDashboardImpl: async () => false }).promise, + ).rejects.toMatchObject({ kind: "external" }); + }); + + it("fails when the install itself fails", async () => { + const server = fakeServer({ installExit: 1 }); + await expect(run(server).promise).rejects.toMatchObject({ + kind: "external", + }); + }); + + it("closes the connection whichever way it ends", async () => { + const ok = fakeServer(); + await run(ok).promise; + expect(ok.session.end).toHaveBeenCalled(); + + const bad = fakeServer({ installExit: 1 }); + await run(bad).promise.catch(() => {}); + expect(bad.session.end).toHaveBeenCalled(); + }); + + it("hands over the password before the installer is asked to use it", async () => { + // The installer writes it into the server's .env partway through a run of + // minutes. Anything that ends the process in between takes the only copy + // with it, and preflight then refuses to install over the container. + const server = fakeServer(); + const seen: Array<{ password: string; at: number }> = []; + let installs = 0; + const original = server.session.run; + server.session.run = vi.fn( + async (command: string, options?: { input?: string }) => { + if (command.includes("bash -s")) installs += 1; + return (original as unknown as typeof server.session.run)( + command, + options, + ); + }, + ) as unknown as SshSession["run"]; + + await run(server, { + onCredentialsBuilt: ({ + credentials, + }: { + credentials: { password: string }; + }) => seen.push({ password: credentials.password, at: installs }), + }).promise; + + expect(seen).toHaveLength(1); + expect(seen[0].password).toBeTruthy(); + // Before the installer had run, not after. + expect(seen[0].at).toBe(0); + }); + + it("does not install when the caller could not keep the credentials", async () => { + // The hook above runs before the installer for a reason, and the caller + // refuses there when it cannot record the password. That only costs a + // retry if nothing has been installed by then. + const server = fakeServer(); + let installs = 0; + const original = server.session.run; + server.session.run = ((command: string, options?: { input?: string }) => { + if (command.includes("bash -s")) installs += 1; + return (original as unknown as typeof server.session.run)( + command, + options, + ); + }) as unknown as SshSession["run"]; + + await expect( + run(server, { + onCredentialsBuilt: () => { + throw new DyadError("nowhere to keep it", DyadErrorKind.External); + }, + }).promise, + ).rejects.toThrow(/nowhere to keep it/); + + expect(installs).toBe(0); + }); + + it("says the link died rather than blaming the version for it", async () => { + // Answering null for a dead connection sends the user to the screen that + // tells them their freshly installed Coolify is too old to drive — for a + // question that never reached it. The install still stands either way. + const server = fakeServer(); + server.session.run = vi.fn( + async (command: string, options?: { input?: string }) => { + const script = options?.input ?? ""; + if (script.includes("constants.coolify.version")) { + throw new SshError( + "timeout", + "the connection stopped answering", + DyadErrorKind.External, + ); + } + if (command.includes("MemTotal")) { + return { + code: 0, + stdout: "os=ubuntu\nmem=1967\ndir=no\ncontainer=\nbusy=no", + stderr: "", + }; + } + if (script.includes("->exists()")) { + return { code: 0, stdout: transcript("yes"), stderr: "" }; + } + if (script.includes("setupDynamicProxyConfiguration")) { + return { code: 0, stdout: transcript("applied"), stderr: "" }; + } + return { code: 0, stdout: "", stderr: "" }; + }, + ) as unknown as SshSession["run"]; + + const result = await run(server).promise; + + expect(result.token).toBeNull(); + expect(result.tokenUnavailableReason).toBe( + "Coolify did not answer while Dyad was opening its API.", + ); + expect(result.credentials.password).toBeTruthy(); + // The step that would have opened it is the one that failed, so it is + // still the user's to do. Reported after it settles, not when it starts. + expect(result.apiEnabled).toBe(false); + }); + + it("passes cancellation through rather than reporting it as a token problem", async () => { + // A cancelled setup is the user's decision, not an instance that could not + // be driven — reporting it as the latter would claim a server exists. + const server = fakeServer(); + server.session.run = vi.fn( + async (command: string, options?: { input?: string }) => { + const script = options?.input ?? ""; + if (script.includes("constants.coolify.version")) { + throw Object.assign(new Error("Cancelled."), { + kind: "user_cancelled", + }); + } + if (command.includes("MemTotal")) { + return { + code: 0, + stdout: "os=ubuntu\nmem=1967\ndir=no\ncontainer=\nbusy=no", + stderr: "", + }; + } + if (script.includes("->exists()")) { + return { code: 0, stdout: transcript("yes"), stderr: "" }; + } + if (script.includes("setupDynamicProxyConfiguration")) { + return { code: 0, stdout: transcript("applied"), stderr: "" }; + } + return { code: 0, stdout: "", stderr: "" }; + }, + ) as unknown as SshSession["run"]; + + await expect(run(server).promise).rejects.toMatchObject({ + kind: "user_cancelled", + }); + }); +}); diff --git a/src/coolify_setup/setup_flow.ts b/src/coolify_setup/setup_flow.ts new file mode 100644 index 0000000000..4c425bbc9a --- /dev/null +++ b/src/coolify_setup/setup_flow.ts @@ -0,0 +1,350 @@ +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import type { + HostKeyVerifier, + SshSession, + SshTarget, +} from "@/ipc/utils/ssh_client"; +import { buildAdminCredentials } from "./admin_credentials"; +import type { AdminCredentials } from "./admin_credentials"; +import { + installCoolify, + preflight, + waitForAdminSeeded, + waitForDashboard, +} from "./install"; +import { SshError } from "@/ipc/utils/ssh_client"; +import { tryAutomaticAccess } from "./api_token"; +import { plainUrlFor, tryEnableHttps } from "./https_setup"; +import type { HttpsOutcome } from "./https_setup"; + +/** + * Taking a bare server to a Coolify Dyad can deploy to. + * + * The order is not arbitrary: each step is the cheapest way to fail from where + * it sits. Looking at the server costs a second and rules out the two problems + * a user can fix immediately; installing costs minutes; and asking for a token + * only makes sense once there is an instance to ask about. + */ + +export type SetupStep = + | "connecting" + | "checking-server" + | "installing" + | "waiting-for-dashboard" + | "verifying-account" + | "securing" + | "creating-token" + | "done"; + +export interface SetupProgress { + step: SetupStep; + /** Installer output, forwarded so a long step does not look like a hang. */ + output?: string; +} + +export interface SetupResult { + dashboardUrl: string; + /** Whether the address above is encrypted. */ + secure: boolean; + /** Present when HTTPS was attempted and could not be had. */ + insecureReason?: string; + credentials: AdminCredentials; + /** + * Absent when Coolify was installed but its API could not be opened. + * + * Not an error: the server is set up and usable either way, and the caller + * asks for a token by hand rather than throwing away a working install. + */ + token: string | null; + /** + * Whether Coolify's API was switched on, which outlives a failed mint. + * + * Not the same question as whether a token came back: Dyad turns the API on + * first, so telling the user to go and enable it is wrong from that point + * onward whatever happens next. + */ + apiEnabled: boolean; + version: string | null; + /** Present when token is null, phrased for the user. */ + tokenUnavailableReason?: string; +} + +/** + * How long the after-a-failure question may take. + * + * It goes to a server that has just failed and may be frozen. The connection + * now notices a peer that stops answering, but only after its own keepalive + * has run out — minutes, against a question worth seconds. Bounded here so + * the answer arrives while the failure it is about is still on screen. + */ +const RECOVERY_PROBE_TIMEOUT_MS = 15_000; + +export interface SetupOptions { + target: SshTarget; + adminEmail: string; + verifyHostKey: HostKeyVerifier; + onProgress?: (progress: SetupProgress) => void; + signal?: AbortSignal; + /** Injected so the flow can be exercised without a server. */ + connect: ( + target: SshTarget, + verify: HostKeyVerifier, + signal?: AbortSignal, + ) => Promise; + waitForDashboardImpl?: typeof waitForDashboard; + waitForAdminSeededImpl?: typeof waitForAdminSeeded; + tryEnableHttpsImpl?: typeof tryEnableHttps; + /** Bounded so a frozen server cannot hold the setup open. */ + recoveryProbeTimeoutMs?: number; + /** A domain the user owns, used instead of one derived from the address. */ + customDomain?: string | null; + /** + * The account exists on the user's server from here on. + * + * Dyad invented this password and never showed it, so anything that fails + * after this point and takes the password with it leaves the user locked out + * of a Coolify that is installed and running. Called again once the address + * settles, since HTTPS can change it. + */ + onAccountKnown?: (account: { + credentials: AdminCredentials; + dashboardUrl: string; + }) => void; + /** + * The password Dyad is about to give the server, before it is given. + * + * Dyad invents this rather than discovering it, so it is knowable a moment + * earlier than the account is — and the installer writes it into Coolify's + * own .env partway through a run that takes minutes. Quitting in between + * would otherwise leave a server nobody has the password for, which + * preflight then refuses to install over. + */ + onCredentialsBuilt?: (account: { + credentials: AdminCredentials; + dashboardUrl: string; + }) => void; +} + +export async function runServerSetup({ + target, + adminEmail, + verifyHostKey, + onProgress, + signal, + connect, + onAccountKnown, + onCredentialsBuilt, + recoveryProbeTimeoutMs = RECOVERY_PROBE_TIMEOUT_MS, + waitForDashboardImpl = waitForDashboard, + waitForAdminSeededImpl = waitForAdminSeeded, + tryEnableHttpsImpl = tryEnableHttps, + customDomain, +}: SetupOptions): Promise { + const report = (step: SetupStep, output?: string) => + onProgress?.({ step, output }); + + report("connecting"); + const session = await connect(target, verifyHostKey, signal); + + try { + report("checking-server"); + const checks = await preflight(session, { signal }); + if (!checks.ready) { + throw new DyadError( + checks.reason ?? "This server cannot be set up automatically.", + DyadErrorKind.Precondition, + ); + } + + const credentials = buildAdminCredentials(adminEmail); + // Before the installer, not after it. What it is about to write into the + // server's .env is already decided here, and the run that writes it is + // where a crash costs the only copy. + onCredentialsBuilt?.({ + credentials, + dashboardUrl: plainUrlFor(target.host), + }); + + report("installing"); + try { + await installCoolify(session, credentials, { + signal, + onOutput: (chunk) => report("installing", chunk), + }); + } catch (error) { + // The installer writes this password into Coolify's own .env and brings + // the stack up partway through its run, so a failure after that point + // leaves an account nobody else knows the password for — and preflight + // refuses to install again once the container exists. Asked without the + // signal, because a cancel is one of the ways to arrive here. + let timer: ReturnType | undefined; + try { + const after = await Promise.race([ + preflight(session, {}), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("The server did not answer.")), + recoveryProbeTimeoutMs, + ); + }), + ]); + // Or could not tell. "Docker is wedged, I cannot say" comes back + // from preflight as the same `alreadyInstalled: false` as an empty + // server, and only one of those means the password opens nothing. + if (after.alreadyInstalled || !after.installedKnown) { + onAccountKnown?.({ + credentials, + dashboardUrl: plainUrlFor(target.host), + }); + } + } catch { + // The probe never answered — it timed out, or the connection went + // with it. That is not an answer of "nothing was installed", and + // treating it as one takes the account off. Keeping a password that + // opens nothing is a nuisance the user can sign out of; dropping the + // only copy of one that does is not recoverable from here. + onAccountKnown?.({ + credentials, + dashboardUrl: plainUrlFor(target.host), + }); + } finally { + clearTimeout(timer); + } + throw error; + } + + // Once the installer has finished, because only then is this password + // certainly on the machine: install.sh writes it into Coolify's own .env + // and the account is seeded from there. Before the checks below, because + // every one of them can fail on a server that is running fine — the + // dashboard poll runs on the user's side of their firewall — and Dyad is + // the only thing that knows what it invented. + onAccountKnown?.({ + credentials, + dashboardUrl: plainUrlFor(target.host), + }); + + report("waiting-for-dashboard"); + const answered = await waitForDashboardImpl(target.host, { signal }); + if (!answered) { + throw new DyadError( + "Coolify was installed, but nothing answered on port 8000. That is " + + "usually a firewall or security group blocking the port rather than " + + "Coolify itself. Open it, then sign in at " + + `${plainUrlFor(target.host)} — Coolify is already on the server, so ` + + "starting over would be refused.", + DyadErrorKind.External, + ); + } + + // Waited for rather than checked once: the account is created by a startup + // service that runs after the dashboard starts answering, so asking + // immediately says no about a server that is only still starting. + report("verifying-account"); + const seeded = await waitForAdminSeededImpl(session, credentials.email, { + signal, + }); + if (!seeded.seeded) { + // The way out belongs in both, and the one with a reason is the one + // people reach — an address Coolify will not take is the ordinary cause. + // Saying only what it objected to leaves an installed server, no + // account, and a preflight that refuses to install again. + throw new DyadError( + (seeded.reason + ? `Coolify would not create its admin account: ${seeded.reason} ` + : `Coolify has not created an admin account for ${credentials.email}. `) + + `The server is installed — open ${plainUrlFor(target.host)} to ` + + `finish setting it up there.`, + DyadErrorKind.External, + ); + } + + // Before the token, so what gets stored is the address the token will + // travel to. It carries root abilities and goes over the wire on every + // deploy, not once at setup, which is what makes plain HTTP worth this. + report("securing"); + let https: HttpsOutcome; + try { + https = await tryEnableHttpsImpl(session, target.host, { + customDomain, + signal, + onProgress: (message) => report("securing", message), + }); + } catch (error) { + // This improves a server that already works, so it must not be able to + // throw one away. A domain left set with no certificate still leaves + // port 8000 serving. + if ((error as { kind?: string }).kind === "user_cancelled") throw error; + // Whatever it could not put back comes with it. The run goes on and + // succeeds from here, so this is the only place that note can still + // reach the finished screen — the failed state it would otherwise + // travel on is never reached. + const leftBehind = (error as Error & { warning?: string }).warning; + const said = + error instanceof Error + ? error.message + : "HTTPS could not be set up on this server."; + https = { + instanceUrl: plainUrlFor(target.host), + secure: false, + // Two sentences, not a run-on: the message is the library's or + // Coolify's and may or may not end in a stop of its own. + reason: leftBehind + ? `${said.replace(/\s*[.:;,]?\s*$/, "")}. ${leftBehind}` + : said, + }; + } + onAccountKnown?.({ credentials, dashboardUrl: https.instanceUrl }); + + report("creating-token"); + const result: SetupResult = { + dashboardUrl: https.instanceUrl, + secure: https.secure, + insecureReason: https.reason, + credentials, + token: null, + apiEnabled: false, + version: null, + }; + try { + const access = await tryAutomaticAccess(session, credentials.email, { + signal, + onApiEnabled: () => { + result.apiEnabled = true; + }, + }); + if (access) { + result.token = access.token; + result.version = access.version; + } else { + result.tokenUnavailableReason = + "This version of Coolify could not be set up automatically."; + } + } catch (error) { + // The install stands whatever happened here, so this reports rather than + // throws: losing a working server because the last step failed would be + // the worse outcome by far. + if ((error as { kind?: string }).kind === "user_cancelled") throw error; + // A lost link is reported as one. Handing back the transport's own + // words would tell the user about a socket when what they need to know + // is that the server stopped answering and the rest is theirs to do. + result.tokenUnavailableReason = + error instanceof SshError + ? // Which step the link died on decides what to say. The API is + // opened first, so once that has taken effect the loss belongs to + // the token step — and naming the API would sit over guidance + // that rightly no longer mentions it. + result.apiEnabled + ? "Coolify stopped answering while Dyad was making a token." + : "Coolify did not answer while Dyad was opening its API." + : error instanceof Error + ? error.message + : "Coolify's API could not be opened automatically."; + } + + report("done"); + return result; + } finally { + session.end(); + } +} diff --git a/src/coolify_setup/sleep.ts b/src/coolify_setup/sleep.ts new file mode 100644 index 0000000000..655fbb5b31 --- /dev/null +++ b/src/coolify_setup/sleep.ts @@ -0,0 +1,18 @@ +/** + * Waits, or stops waiting when the run is cancelled. + * + * A bare timer means a cancel is noticed at the top of the next loop, which + * for these intervals is seconds after the user pressed the button. + */ +export function sleep(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(finish, ms); + function finish() { + clearTimeout(timer); + signal?.removeEventListener("abort", finish); + resolve(); + } + signal?.addEventListener("abort", finish, { once: true }); + }); +} diff --git a/src/coolify_setup/state.ts b/src/coolify_setup/state.ts new file mode 100644 index 0000000000..57748bcc09 --- /dev/null +++ b/src/coolify_setup/state.ts @@ -0,0 +1,178 @@ +import type { InvocationRef } from "@/state_machines/invocation_ref"; +import type { + StaleOperationIgnoreReason, + TransitionResult as GenericTransitionResult, +} from "@/state_machines/types"; + +/** + * Written out here rather than imported from the wire schema. + * + * A pure machine module may not reach into `src/ipc` at all — the boundary + * check does not care that an import is type-only, and it is right not to: + * the rule is about which side owns the definition. The schemas beside + * `SetupSnapshotSchema` assert against these, so neither can drift. + */ +export type SetupStep = + | "connecting" + | "checking-server" + | "installing" + | "waiting-for-dashboard" + | "verifying-account" + | "securing" + | "creating-token" + | "done"; + +export interface SetupTarget { + host: string; + username: string; + port?: number; + adminEmail: string; + customDomain?: string; +} + +export interface SetupResult { + dashboardUrl: string; + secure: boolean; + insecureReason: string | null; + adminEmail: string; + adminPassword: string; + tokenStored: boolean; + /** Whether Coolify's API was switched on, which outlives a failed mint. */ + apiEnabled: boolean; + tokenUnavailableReason: string | null; + version: string | null; +} + +/** + * Types for the machine that sets up one Coolify server. + * + * Types only: the pure transition lives in `transition.ts` and the side + * effects in `controller.ts`. The state belongs to the main process because + * that is where the work happens — an install outlives the screen that started + * it, since leaving that screen is invited and a background refetch can + * replace it. A copy in the renderer goes stale the moment either happens. + * + * Every event from a running setup echoes its invocation ref, so an answer + * from a run that was cancelled or superseded cannot revive or overwrite + * whatever the user is looking at now. Correlation is by operation rather than + * by address: the same server can be set up twice, and the second run must not + * inherit the first one's answers. + * + * Nothing here reaches into `src/ipc`: a pure machine module owns its own + * types, and the wire schema asserts against them rather than the other way + * round. + */ + +export const COOLIFY_SETUP_INVOCATION_KIND = "coolify-setup" as const; + +/** Keyed by host: one machine at a time, and which one matters. */ +export type CoolifySetupInvocationRef = InvocationRef< + typeof COOLIFY_SETUP_INVOCATION_KIND, + string +>; + +/** How much installer output is kept. The end is what explains a failure. */ +export const MAX_LOG_CHARS = 20_000; + +export interface CoolifySetupRunning { + type: "running"; + host: string; + invocationRef: CoolifySetupInvocationRef; + step: SetupStep; + log: string; + /** True once the user has asked it to stop and before it has. */ + stopping: boolean; +} + +export interface CoolifySetupDone { + type: "done"; + host: string; + invocationRef: CoolifySetupInvocationRef; + result: SetupResult; +} + +export interface CoolifySetupFailed { + type: "failed"; + host: string; + invocationRef: CoolifySetupInvocationRef; + message: string; + /** Kept: the installer's own last words are what explain the failure. */ + log: string; + /** Cancelling is the user's decision, not a fault of the install. */ + cancelled: boolean; + /** + * Something the run changed and could not change back. + * + * Kept apart from `message`, which says why the run ended: this is what is + * left for the user to do about it, and it outlives a cancel — where the + * panel says nothing about the ending itself. + */ + warning?: string; +} + +export type CoolifySetupState = + | { type: "idle" } + | CoolifySetupRunning + | CoolifySetupDone + | CoolifySetupFailed; + +export const IDLE: CoolifySetupState = { type: "idle" }; + +/** + * What the machine asks the controller to do. + * + * Kept out of the handler so the rules that decide them are testable without + * a server: one setup at a time, and cancelling only something that is + * running. + */ +export type CoolifySetupCommand = + | { + type: "launch"; + invocationRef: CoolifySetupInvocationRef; + target: SetupTarget; + } + | { type: "abort"; invocationRef: CoolifySetupInvocationRef }; + +export type CoolifySetupEvent = + | { + type: "start-requested"; + invocationRef: CoolifySetupInvocationRef; + target: SetupTarget; + } + | { type: "cancel-requested" } + | { + type: "progress"; + invocationRef: CoolifySetupInvocationRef; + step: SetupStep; + output?: string; + } + | { + type: "succeeded"; + invocationRef: CoolifySetupInvocationRef; + result: SetupResult; + } + | { + type: "failed"; + invocationRef: CoolifySetupInvocationRef; + message: string; + cancelled: boolean; + /** Something the run changed and could not change back. */ + warning?: string; + } + /** The user has read the terminal screen and moved on. */ + | { type: "dismissed" }; + +export type CoolifySetupIgnoreReason = + | StaleOperationIgnoreReason + /** One server at a time: two installs would fight over the same docker. */ + | "already-running" + /** Nothing is running, so there is nothing this could belong to. */ + | "not-running" + /** Dismissing while it runs would hide work that is still going on. */ + | "still-running"; + +export type TransitionResult = GenericTransitionResult< + CoolifySetupState, + CoolifySetupCommand, + CoolifySetupIgnoreReason +>; diff --git a/src/coolify_setup/tinker.test.ts b/src/coolify_setup/tinker.test.ts new file mode 100644 index 0000000000..a4a0aff648 --- /dev/null +++ b/src/coolify_setup/tinker.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it, vi } from "vitest"; +import { + answerLine, + extractOutput, + runTinker, + tinkerCommand, + wrapScript, +} from "./tinker"; +import type { SshSession } from "@/ipc/utils/ssh_client"; + +/** + * A transcript captured from Coolify 4.3.2 rather than written by hand. + * + * The shape is the whole reason this module exists: tinker echoes every line it + * is fed with a `> ` prefix, so the markers appear twice — and the first line of + * real output shares a line with the last prompt. + */ +const REAL_TRANSCRIPT = [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "", + '> echo "line-one" . PHP_EOL; echo "line-two" . PHP_EOL;', + '> echo "__DYAD_OUT_END__" . PHP_EOL;', + "> __DYAD_OUT_START__", + "line-one", + "line-two", + "__DYAD_OUT_END__", +].join("\n"); + +/** + * The same shape from a later Coolify, captured the same way. + * + * Two versions apart, psysh still flushes its last prompt onto the line the + * first output lands on — which is the whole basis for telling real output + * from the echo of the script that produced it. + */ +const REAL_4_3_14 = [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "", + "> echo config('constants.coolify.version');", + '> echo PHP_EOL . "__DYAD_OUT_END__" . PHP_EOL;', + "> __DYAD_OUT_START__", + "4.3.14", + "__DYAD_OUT_END__", +].join("\n"); + +/** + * The same again, with psysh redrawing an echo it could not fit. + * + * A long input line comes back carrying a carriage return and overwritten + * partway, so the echo is neither complete nor clean. It still lands above + * the opening marker, which is what keeps it out of the answer. + */ +const REAL_MANGLED_ECHO = [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "", + "> \rexists() ? 'yes' : 'no';", + '> echo PHP_EOL . "__DYAD_OUT_END__" . PHP_EOL;', + "> __DYAD_OUT_START__", + "no", + "__DYAD_OUT_END__", +].join("\n"); + +describe("transcripts from a real Coolify", () => { + it("reads the version a 4.3.14 server gave back", () => { + expect(extractOutput(REAL_4_3_14)).toBe("4.3.14"); + expect( + answerLine(extractOutput(REAL_4_3_14) ?? "", (l) => /^\d+\.\d+/.test(l)), + ).toBe("4.3.14"); + }); + + it("is not confused by an echo psysh redrew", () => { + // The mangled line carries a CR and part of the script, including the + // words "yes" and "no" — everything the reader below is looking for. + const region = extractOutput(REAL_MANGLED_ECHO); + expect(region).toBe("no"); + expect(answerLine(region ?? "", (l) => l === "no")).toBe("no"); + expect(answerLine(region ?? "", (l) => l === "yes")).toBeNull(); + }); +}); + +describe("reading the answer out of a noisy region", () => { + it("finds it beside whatever else Coolify printed", () => { + const region = ["PHP Deprecated: Some notice", "yes", ""].join("\n"); + expect(answerLine(region, (l) => l === "yes")).toBe("yes"); + }); + + it("will not take a line that merely mentions it", () => { + // A warning naming the answer is not the answer. + expect( + answerLine("Warning: expected yes here", (l) => l === "yes"), + ).toBeNull(); + }); + + it("answers nothing when the region has none", () => { + expect(answerLine("no\nstill-disabled", (l) => l === "yes")).toBeNull(); + expect(answerLine("", (l) => l === "yes")).toBeNull(); + }); +}); + +describe("finding our output however psysh prompts", () => { + it("reads it when the prompt is missing or repeated", () => { + // The prompt sharing a line with the output is an artefact of when psysh + // flushes. A version that stopped echoing, or that stacked prompts, would + // otherwise make every call fail — and the caller reports that as Coolify + // refusing to create an account, on a server where it exists. + for (const marker of [ + "__DYAD_OUT_START__", + "> __DYAD_OUT_START__", + "> > > __DYAD_OUT_START__", + ]) { + expect( + extractOutput([marker, "answer", "__DYAD_OUT_END__"].join("\n")), + ).toBe("answer"); + } + }); + + it("still does not take the line that produced it for the output", () => { + // The echoed script line carries the marker too, and matching it would + // return the rest of the script as the answer. + expect( + extractOutput( + [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "> __DYAD_OUT_START__", + "answer", + "__DYAD_OUT_END__", + ].join("\n"), + ), + ).toBe("answer"); + }); +}); + +function fakeSession( + onRun: (command: string, options?: { input?: string }) => { stdout: string }, +): SshSession & { calls: Array<{ command: string; input?: string }> } { + const calls: Array<{ command: string; input?: string }> = []; + return { + calls, + run: vi.fn(async (command: string, options?: { input?: string }) => { + calls.push({ command, input: options?.input }); + return { code: 0, stderr: "", ...onRun(command, options) }; + }) as unknown as SshSession["run"], + end: vi.fn(), + }; +} + +describe("the container it runs in", () => { + it("refuses a name that could end the command", async () => { + // The name is interpolated into a command that runs as root, and both + // command shapes build it themselves. + expect(() => tinkerCommand("coolify; rm -rf /")).toThrow(); + await expect( + runTinker( + { run: async () => ({ code: 0, stdout: "", stderr: "" }) } as never, + "echo 1;", + { + container: "coolify; rm -rf /", + env: { A: "b" }, + }, + ), + ).rejects.toMatchObject({ kind: "validation" }); + }); +}); + +describe("extractOutput", () => { + it("takes the printed output, not the echo of the line printing it", () => { + // Both appear in the transcript. Matching the marker without its prompt + // would find the echoed input first and return the script back. + expect(extractOutput(REAL_TRANSCRIPT)).toBe("line-one\nline-two"); + }); + + it("says nothing was found when the script never ran", () => { + // A container still starting answers with an error and no markers at all. + // Reading that as empty output would let a caller treat "no token" as a + // fact rather than as a failure. + expect( + extractOutput("Error response from daemon: container not running"), + ).toBeNull(); + }); + + it("says nothing was found when the output stops halfway", () => { + const truncated = REAL_TRANSCRIPT.split("\n").slice(0, 6).join("\n"); + expect(extractOutput(truncated)).toBeNull(); + }); + + it("finds output from a script that printed no trailing newline", () => { + // The real shape before wrapScript was fixed: value and marker on one line. + const glued = [ + '> echo "__DYAD_OUT_START__" . PHP_EOL;', + "> __DYAD_OUT_START__", + "yes", + "__DYAD_OUT_END__", + ].join("\n"); + expect(extractOutput(glued)).toBe("yes"); + }); + + it("survives carriage returns", () => { + expect(extractOutput(REAL_TRANSCRIPT.replace(/\n/g, "\r\n"))).toBe( + "line-one\nline-two", + ); + }); +}); + +describe("tinkerCommand", () => { + it("attaches stdin", () => { + // Without -i docker does not attach stdin, so the script is never read and + // the command succeeds having done nothing at all. + expect(tinkerCommand()).toContain("docker exec -i "); + }); +}); + +describe("runTinker", () => { + it("feeds the script on stdin rather than the command line", async () => { + const session = fakeSession(() => ({ stdout: REAL_TRANSCRIPT })); + await runTinker(session, 'echo "line-one" . PHP_EOL;'); + + expect(session.calls[0].input).toContain('echo "line-one" . PHP_EOL;'); + expect(session.calls[0].command).not.toContain("line-one"); + }); + + it("passes a secret by name and value, quoted", async () => { + const session = fakeSession(() => ({ stdout: REAL_TRANSCRIPT })); + await runTinker(session, "echo getenv('DYAD_SECRET');", { + env: { DYAD_SECRET: "p@ssw0rd-+=" }, + }); + + expect(session.calls[0].command).toContain("-e DYAD_SECRET='p@ssw0rd-+='"); + // The value stays out of the script, so it never meets PHP's parser too. + expect(session.calls[0].input).not.toContain("p@ssw0rd"); + }); + + it("refuses a value that would escape its quoting", async () => { + const session = fakeSession(() => ({ stdout: REAL_TRANSCRIPT })); + // Rejected rather than escaped: getting this subtly wrong runs arbitrary + // text as a command on the user's server. + await expect( + runTinker(session, "echo 1;", { + env: { DYAD_SECRET: "a'; rm -rf /; '" }, + }), + ).rejects.toMatchObject({ kind: "internal" }); + }); + + it("refuses a variable name that is not a plain identifier", async () => { + const session = fakeSession(() => ({ stdout: REAL_TRANSCRIPT })); + await expect( + runTinker(session, "echo 1;", { env: { "X; rm -rf /": "v" } }), + ).rejects.toMatchObject({ kind: "internal" }); + }); + + it("fails loudly when the script did not run", async () => { + const session = fakeSession(() => ({ stdout: "container not running" })); + await expect(runTinker(session, "echo 1;")).rejects.toMatchObject({ + kind: "external", + }); + }); + + it("targets a named container when asked", async () => { + const session = fakeSession(() => ({ stdout: REAL_TRANSCRIPT })); + await runTinker(session, "echo 1;", { container: "coolify-staging" }); + expect(session.calls[0].command).toContain(" coolify-staging "); + }); +}); + +describe("wrapScript", () => { + it("breaks the line before the closing marker", () => { + // Captured from a real run: a script ending without PHP_EOL produced + // `yes__DYAD_OUT_END__`, and the marker was never found. + expect(wrapScript("echo 'yes';")).toContain( + 'echo PHP_EOL . "__DYAD_OUT_END__"', + ); + }); + + it("puts the body between the markers", () => { + const wrapped = wrapScript("echo 42;"); + const lines = wrapped.split("\n"); + expect(lines[0]).toContain("__DYAD_OUT_START__"); + expect(lines[1]).toBe("echo 42;"); + expect(lines[2]).toContain("__DYAD_OUT_END__"); + }); +}); diff --git a/src/coolify_setup/tinker.ts b/src/coolify_setup/tinker.ts new file mode 100644 index 0000000000..fa06a6fe9f --- /dev/null +++ b/src/coolify_setup/tinker.ts @@ -0,0 +1,189 @@ +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import type { SshSession } from "@/ipc/utils/ssh_client"; + +/** + * Running PHP inside the Coolify container. + * + * Coolify has no supported interface for what setup needs — turning the API + * on, minting a token, creating and finding the first user, setting the + * instance domain, reading the version before the API is reachable — so each + * is done by driving Laravel directly. That is a workaround, not an interface: + * every caller carries a TODO naming what an official API would replace. + */ + +/** + * Marks where our output starts and stops. + * + * tinker echoes every line it is fed, prefixed `> `, and the first line of real + * output lands on the same line as the last prompt. So the transcript contains + * the marker twice: once in the echo of the line that prints it, once as the + * output itself. Looking for the marker anywhere on a line would take the + * echo, so the match is anchored to a whole line — which the echo cannot be, + * because it carries the script around the marker. + */ +const START = "__DYAD_OUT_START__"; +const END = "__DYAD_OUT_END__"; + +/** + * The command that feeds a script to tinker. + * + * `-i` matters: without it docker does not attach stdin, the script is never + * read, and the whole thing succeeds having done nothing. + */ +export function tinkerCommand(container = "coolify"): string { + assertSafeContainer(container); + return `docker exec -i ${container} php artisan tinker --no-ansi`; +} + +/** Docker's own grammar. The name is interpolated into a root command. */ +function assertSafeContainer(container: string): void { + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(container)) { + throw new DyadError( + `Refusing to run against an unsafe container name: ${container}`, + DyadErrorKind.Validation, + ); + } +} + +/** + * Wraps a script so its output can be found in the transcript. + * + * The closing marker starts with a newline of its own because a script whose + * last echo omits PHP_EOL would otherwise leave the marker stuck to the end of + * the value — `yes__DYAD_OUT_END__` — and nothing would find it. Putting the + * break here means callers do not have to remember. + */ +export function wrapScript(body: string): string { + return [ + `echo "${START}" . PHP_EOL;`, + body.trim(), + `echo PHP_EOL . "${END}" . PHP_EOL;`, + ].join("\n"); +} + +/** + * Pulls our output back out of a tinker transcript. + * + * The opening marker is matched with the prompt it shares a line with, because + * that is what distinguishes the real output from the echo of the line that + * produced it. The prompt is allowed to be absent or repeated: it is an + * artefact of psysh flushing a prompt before the output arrives, and nothing + * here should turn a change in that into every call failing. Either way the + * echoed line cannot match, because it carries the script around the marker. + */ +const START_LINE = new RegExp(`^(?:>\\s*)*${START}$`); + +export function extractOutput(transcript: string): string | null { + const lines = transcript.split(/\r?\n/); + const startAt = lines.findIndex((line) => START_LINE.test(line.trim())); + if (startAt === -1) return null; + const rest = lines.slice(startAt + 1); + const endAt = rest.findIndex((line) => line.trimEnd() === END); + if (endAt === -1) return null; + return rest.slice(0, endAt).join("\n").trim(); +} + +/** + * Finds the answer among whatever else the region carries. + * + * Insurance rather than something observed: two real transcripts, 4.3.2 and + * 4.3.14, both came back with nothing but the answer between the markers. But + * a notice from Coolify would only have to happen once for a reader that + * demands the whole region to report a working server as broken, and reading + * a line at a time costs nothing when there is only one. + * + * Exactly per line rather than anywhere in it, so a notice that mentions the + * answer is not taken for it. Trimmed per line because psysh writes a + * carriage return mid-transcript when it redraws a long echo — the region's + * own trim only reaches the ends. + */ +export function answerLine( + region: string, + matches: (line: string) => boolean, +): string | null { + for (const line of region.split(/\r?\n/)) { + const said = line.trim(); + if (said && matches(said)) return said; + } + return null; +} + +export interface TinkerOptions { + /** + * Values the script reads with getenv(). + * + * Secrets travel this way rather than being written into the script, so they + * never have to survive PHP's parser as well as the shell's. They are still + * single-quoted into the docker command — sshd forwards almost no environment + * by default, so there is no way to hand them over out of band. + */ + env?: Record; + container?: string; + signal?: AbortSignal; + /** Passed through: a tinker one-liner has a short honest answer. */ + timeoutMs?: number; +} + +/** + * Renders values as docker -e arguments. + * + * Rejects rather than escapes: a value carrying a quote or a backslash would + * end the quoting, and getting that subtly wrong runs arbitrary text as a + * command on the user's server. Everything passed here is generated by Dyad, + * so a rejection is a bug on our side rather than something a user hits. + */ +function envArgs(env: Record): string { + return Object.entries(env) + .map(([name, value]) => { + if (!/^[A-Z][A-Z0-9_]*$/.test(name)) { + throw new DyadError( + `Unsafe environment variable name: ${name}`, + DyadErrorKind.Internal, + ); + } + if (/['\\`$\n\r]/.test(value)) { + throw new DyadError( + `Value for ${name} contains a character that cannot be passed safely`, + DyadErrorKind.Internal, + ); + } + return `-e ${name}='${value}'`; + }) + .join(" "); +} + +/** + * Runs a PHP script in the Coolify container and returns what it printed. + * + * Throws rather than returning an empty string when the markers are missing, + * because that means the script did not run — a container that is not up yet, + * or a tinker that died — and a caller reading "" as "no token" would go on to + * do something worse than stopping. + */ +export async function runTinker( + session: SshSession, + script: string, + { env = {}, container = "coolify", signal, timeoutMs }: TinkerOptions = {}, +): Promise { + assertSafeContainer(container); + const names = envArgs(env); + const command = names + ? `docker exec -i ${names} ${container} php artisan tinker --no-ansi` + : tinkerCommand(container); + + const result = await session.run(command, { + input: wrapScript(script) + "\n", + signal, + timeoutMs, + }); + + const output = extractOutput(result.stdout); + if (output === null) { + throw new DyadError( + "Coolify did not answer as expected while being set up. It may still be " + + "starting — wait a moment and try again.", + DyadErrorKind.External, + ); + } + return output; +} diff --git a/src/coolify_setup/transition.test.ts b/src/coolify_setup/transition.test.ts new file mode 100644 index 0000000000..c25ba4043f --- /dev/null +++ b/src/coolify_setup/transition.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, it } from "vitest"; +import { coolifySetupTransition } from "./transition"; +import { + IDLE, + MAX_LOG_CHARS, + COOLIFY_SETUP_INVOCATION_KIND, + type CoolifySetupCommand, + type CoolifySetupEvent, + type CoolifySetupInvocationRef, + type CoolifySetupState, +} from "./state"; +import type { SetupResult } from "@/ipc/types/coolify_setup"; + +const ref = (host: string, operationId: string): CoolifySetupInvocationRef => ({ + kind: COOLIFY_SETUP_INVOCATION_KIND, + entityKey: host, + operationId, +}); + +const HOST = "203.0.113.5"; +const REF = ref(HOST, "op-1"); +// A second run of the SAME server: the host cannot tell these apart, which is +// why correlation is by operation and not by address. +const AGAIN = ref(HOST, "op-2"); +const OTHER = ref("198.51.100.9", "op-3"); + +const TARGET = { host: HOST, username: "root", adminEmail: "me@gmail.com" }; + +const RESULT: SetupResult = { + dashboardUrl: "https://203.0.113.5.sslip.io", + secure: true, + insecureReason: null, + adminEmail: "me@gmail.com", + adminPassword: "Abc123@xyz", + tokenStored: true, + apiEnabled: true, + tokenUnavailableReason: null, + version: "4.3.2", +}; + +const running = (over: Record = {}): CoolifySetupState => + ({ + type: "running", + host: HOST, + invocationRef: REF, + step: "installing", + log: "", + stopping: false, + ...over, + }) as CoolifySetupState; + +const done = (): CoolifySetupState => ({ + type: "done", + host: HOST, + invocationRef: REF, + result: RESULT, +}); +const failed = (): CoolifySetupState => ({ + type: "failed", + host: HOST, + invocationRef: REF, + message: "boom", + log: "output", + cancelled: false, +}); + +const ALL_STATES: CoolifySetupState[] = [IDLE, running(), done(), failed()]; +const ALL_EVENTS: CoolifySetupEvent[] = [ + { type: "start-requested", invocationRef: REF, target: TARGET }, + { type: "cancel-requested" }, + { type: "progress", invocationRef: REF, step: "installing", output: "x" }, + { type: "succeeded", invocationRef: REF, result: RESULT }, + { type: "failed", invocationRef: REF, message: "boom", cancelled: false }, + { type: "dismissed" }, +]; + +/** The state a transition settles on, whether it applied or was ignored. */ +const next = (state: CoolifySetupState, event: CoolifySetupEvent) => + coolifySetupTransition(state, event).state; + +const commandsOf = ( + state: CoolifySetupState, + event: CoolifySetupEvent, +): readonly CoolifySetupCommand[] => { + const result = coolifySetupTransition(state, event); + return result.kind === "applied" ? result.commands : []; +}; + +const reasonOf = (state: CoolifySetupState, event: CoolifySetupEvent) => { + const result = coolifySetupTransition(state, event); + return result.kind === "ignored" ? result.reason : null; +}; + +describe("totality", () => { + it("answers every event from every state", () => { + // The events come from a process that does not know what the user has + // done since, so there is no combination that cannot arrive. + for (const state of ALL_STATES) { + for (const event of ALL_EVENTS) { + expect(() => coolifySetupTransition(state, event)).not.toThrow(); + expect(coolifySetupTransition(state, event)).toBeTruthy(); + } + } + }); + + it("never mutates the state it was given", () => { + for (const state of ALL_STATES) { + for (const event of ALL_EVENTS) { + const before = JSON.stringify(state); + coolifySetupTransition(state, event); + expect(JSON.stringify(state)).toBe(before); + } + } + }); +}); + +describe("starting", () => { + it("begins at connecting with nothing said yet", () => { + expect( + next(IDLE, { + type: "start-requested", + invocationRef: REF, + target: TARGET, + }), + ).toEqual({ + type: "running", + host: HOST, + invocationRef: REF, + step: "connecting", + log: "", + stopping: false, + }); + }); + + it("replaces a terminal screen rather than keeping it beside the new run", () => { + expect( + next(done(), { + type: "start-requested", + invocationRef: OTHER, + target: { ...TARGET, host: "198.51.100.9" }, + }), + ).toMatchObject({ type: "running", host: "198.51.100.9" }); + }); +}); + +describe("while it runs", () => { + it("follows the step", () => { + const stepped = next(running(), { + type: "progress", + invocationRef: REF, + step: "securing", + }); + expect(stepped).toMatchObject({ type: "running", step: "securing" }); + }); + + it("accumulates output", () => { + let state = running(); + state = next(state, { + type: "progress", + invocationRef: REF, + step: "installing", + output: "one ", + }); + state = next(state, { + type: "progress", + invocationRef: REF, + step: "installing", + output: "two", + }); + expect(state).toMatchObject({ log: "one two" }); + }); + + it("keeps the end of a long installer's output", () => { + // The end is what explains a failure, and an installer can print a lot. + const state = next(running({ log: "a".repeat(MAX_LOG_CHARS) }), { + type: "progress", + invocationRef: REF, + step: "installing", + output: "TAIL", + }); + const log = (state as { log: string }).log; + expect(log).toHaveLength(MAX_LOG_CHARS); + expect(log.endsWith("TAIL")).toBe(true); + }); + + it("records that the user has asked it to stop", () => { + expect(next(running(), { type: "cancel-requested" })).toMatchObject({ + stopping: true, + }); + }); +}); + +describe("what it asks the controller to do", () => { + // The rules live here rather than beside the effect, so they can be tested + // without a server. + + it("asks for the work to be launched when nothing is running", () => { + expect( + commandsOf(IDLE, { + type: "start-requested", + invocationRef: REF, + target: TARGET, + }), + ).toEqual([{ type: "launch", invocationRef: REF, target: TARGET }]); + }); + + it("refuses a second setup while one is going, and launches nothing", () => { + const state = running(); + const event: CoolifySetupEvent = { + type: "start-requested", + invocationRef: AGAIN, + target: TARGET, + }; + expect(reasonOf(state, event)).toBe("already-running"); + expect(commandsOf(state, event)).toEqual([]); + expect(next(state, event)).toBe(state); + }); + + it("asks for the running invocation to be aborted, not the requested one", () => { + // The abort has to name what is actually going on, or a cancel arriving + // just after a supersede would stop the wrong run. + expect(commandsOf(running(), { type: "cancel-requested" })).toEqual([ + { type: "abort", invocationRef: REF }, + ]); + }); + + it("does not remake the state when it is already stopping", () => { + // A second Cancel — a double click inside the round trip, or another + // window — must not hand back a new object that says the same thing. + const state = running({ stopping: true }); + const result = coolifySetupTransition(state, { type: "cancel-requested" }); + + expect(result.state).toBe(state); + expect(commandsOf(state, { type: "cancel-requested" })).toEqual([ + { type: "abort", invocationRef: REF }, + ]); + }); + + it("aborts nothing when nothing is running", () => { + expect(reasonOf(IDLE, { type: "cancel-requested" })).toBe("not-running"); + expect(commandsOf(done(), { type: "cancel-requested" })).toEqual([]); + }); + + it("says why it ignored an answer from a superseded run", () => { + expect( + reasonOf(running(), { + type: "succeeded", + invocationRef: AGAIN, + result: RESULT, + }), + ).toBe("stale-operation"); + }); +}); + +describe("answers from a run that is no longer the one in hand", () => { + // The reason this is a machine. A run keeps going after the panel showing + // it is gone, so its answers arrive against whatever state came next. + + it("ignores progress for another server", () => { + const state = running(); + expect( + next(state, { + type: "progress", + invocationRef: OTHER, + step: "securing", + }), + ).toBe(state); + }); + + it("ignores a result for another server", () => { + const state = running(); + expect( + next(state, { type: "succeeded", invocationRef: OTHER, result: RESULT }), + ).toBe(state); + }); + + it("keeps what the run could not put back", () => { + // Separate from the message, which says why it ended. A cancel says + // nothing about the ending, so this is the only thing the panel has to + // show for a domain that would not come back off. + expect( + next(running(), { + type: "failed", + invocationRef: REF, + message: "Cancelled.", + cancelled: true, + warning: "Coolify may still be configured for x.sslip.io.", + }), + ).toMatchObject({ + type: "failed", + cancelled: true, + warning: "Coolify may still be configured for x.sslip.io.", + }); + }); + + it("ignores a result that arrives after the screen was dismissed", () => { + // Exactly the shape that put a finished install over a panel the user had + // already moved on from. + expect( + next(IDLE, { type: "succeeded", invocationRef: REF, result: RESULT }), + ).toBe(IDLE); + }); + + it("ignores a failure from an earlier run of the same server", () => { + // Start, cancel, start again: the host is identical, so only the + // operation identity can tell the first run's answer from the second's. + const state = running({ invocationRef: AGAIN }); + expect( + next(state, { + type: "failed", + invocationRef: REF, + message: "late", + cancelled: false, + }), + ).toBe(state); + }); + + it("ignores a second result for a run already finished", () => { + const state = done(); + expect( + next(state, { type: "succeeded", invocationRef: REF, result: RESULT }), + ).toBe(state); + }); +}); + +describe("finishing", () => { + it("carries the result through", () => { + expect( + next(running(), { + type: "succeeded", + invocationRef: REF, + result: RESULT, + }), + ).toEqual({ + type: "done", + host: HOST, + invocationRef: REF, + result: RESULT, + }); + }); + + it("keeps the output when it fails, since that is what explains it", () => { + expect( + next(running({ log: "3/6 Pulling..." }), { + type: "failed", + invocationRef: REF, + message: "exit 1", + cancelled: false, + }), + ).toEqual({ + type: "failed", + host: HOST, + invocationRef: REF, + message: "exit 1", + log: "3/6 Pulling...", + cancelled: false, + }); + }); + + it("marks a cancellation as one, so it is not reported as a fault", () => { + expect( + next(running({ stopping: true }), { + type: "failed", + invocationRef: REF, + message: "Cancelled.", + cancelled: true, + }), + ).toMatchObject({ type: "failed", cancelled: true }); + }); +}); + +describe("dismissing", () => { + it("clears a finished screen", () => { + expect(next(done(), { type: "dismissed" })).toEqual(IDLE); + }); + + it("clears a failed screen", () => { + expect(next(failed(), { type: "dismissed" })).toEqual(IDLE); + }); + + it("does not clear a running one", () => { + // There would still be an install going on, with nothing showing it. + const state = running(); + expect(next(state, { type: "dismissed" })).toBe(state); + }); +}); diff --git a/src/coolify_setup/transition.ts b/src/coolify_setup/transition.ts new file mode 100644 index 0000000000..2581ed8f7f --- /dev/null +++ b/src/coolify_setup/transition.ts @@ -0,0 +1,154 @@ +import { + IDLE, + MAX_LOG_CHARS, + type CoolifySetupCommand, + type CoolifySetupEvent, + type CoolifySetupRunning, + type CoolifySetupState, + type TransitionResult, +} from "./state"; +import { sameInvocationRef } from "@/state_machines/invocation_ref"; +import { + STALE_OPERATION_IGNORE_REASON, + change, + ignore, + stay, +} from "@/state_machines/types"; + +function appendLog(existing: string, chunk: string): string { + const combined = existing + chunk; + return combined.length > MAX_LOG_CHARS + ? combined.slice(combined.length - MAX_LOG_CHARS) + : combined; +} + +/** + * Returns the running state only when the event claims the live invocation. + * + * This is what stops a setup the user has left behind from writing progress, + * a finished screen, or an error over whatever replaced it. + */ +function runningFor( + state: CoolifySetupState, + ref: CoolifySetupRunning["invocationRef"], +): CoolifySetupRunning | null { + if (state.type !== "running") return null; + return sameInvocationRef(state.invocationRef, ref) ? state : null; +} + +/** Progress and completions all answer the same question about identity. */ +function notForTheRunInHand(state: CoolifySetupState): TransitionResult { + return ignore( + state, + state.type === "running" ? STALE_OPERATION_IGNORE_REASON : "not-running", + ); +} + +/** + * Pure transition function for the Coolify server setup machine. + * + * Total: every event is answered from every state, and one that does not apply + * is ignored with a reason rather than throwing. That is the point rather than + * a convenience — these events come from a process that does not know what the + * user has done since, so an answer for a run that is no longer the one in + * hand is ordinary traffic, not an error. + */ +export function coolifySetupTransition( + state: CoolifySetupState, + event: CoolifySetupEvent, +): TransitionResult { + switch (event.type) { + case "start-requested": { + // One at a time, decided here rather than by a check beside the effect. + // Two installs on one machine would interleave their output and fight + // over the same docker state. + if (state.type === "running") return ignore(state, "already-running"); + const command: CoolifySetupCommand = { + type: "launch", + invocationRef: event.invocationRef, + target: event.target, + }; + return change( + { + type: "running", + host: event.target.host, + invocationRef: event.invocationRef, + step: "connecting", + log: "", + stopping: false, + }, + [command], + ); + } + + case "cancel-requested": { + // Nothing to stop is not a failure; it is the ordinary answer to a + // cancel that raced the run finishing. + if (state.type !== "running") return ignore(state, "not-running"); + const abort: CoolifySetupCommand = { + type: "abort", + invocationRef: state.invocationRef, + }; + // Already stopping: the abort is worth sending again, since aborting + // twice is the same as aborting once — but the state is not worth + // remaking. A value-equal snapshot with a new reference is a change + // that changes nothing, and every window would be told about it. + if (state.stopping) return stay(state, [abort]); + return change({ ...state, stopping: true }, [abort]); + } + + case "progress": { + const running = runningFor(state, event.invocationRef); + if (!running) return notForTheRunInHand(state); + const log = event.output + ? appendLog(running.log, event.output) + : running.log; + // Same answer as last time. Returning a new object anyway would send + // every window a change that changes nothing. + if (running.step === event.step && running.log === log) { + return stay(running, []); + } + return change({ ...running, step: event.step, log }); + } + + case "succeeded": { + const running = runningFor(state, event.invocationRef); + if (!running) return notForTheRunInHand(state); + return change({ + type: "done", + host: running.host, + invocationRef: running.invocationRef, + result: event.result, + }); + } + + case "failed": { + const running = runningFor(state, event.invocationRef); + if (!running) return notForTheRunInHand(state); + return change({ + type: "failed", + host: running.host, + invocationRef: running.invocationRef, + message: event.message, + log: running.log, + cancelled: event.cancelled, + // Carried, not derived: what a run could not put back is not + // recoverable from why it ended, and a cancel says nothing else. + warning: event.warning, + }); + } + + case "dismissed": + if (state.type === "running") return ignore(state, "still-running"); + if (state.type === "idle") return ignore(state, "not-running"); + return change(IDLE); + + default: { + // Total by construction: adding an event without answering it above + // stops this assignment compiling. + const unanswered: never = event; + void unanswered; + return ignore(state, "not-running"); + } + } +} diff --git a/src/distributed_machines/boundary_inventory.test_support.ts b/src/distributed_machines/boundary_inventory.test_support.ts index 41db7045df..d02e262bf2 100644 --- a/src/distributed_machines/boundary_inventory.test_support.ts +++ b/src/distributed_machines/boundary_inventory.test_support.ts @@ -278,6 +278,7 @@ export const nonRemoteDispatchOrEnqueueInventory = [ // A local main-process machine: dispatch here is its own transition, not // distributed-machine transport. owned("coolify_deploy/controller.ts", 7), + owned("coolify_setup/controller.ts", 6), owned("hooks/useRunApp.ts", 1), owned("ipc/services/app_runtime_service.ts", 2), owned("ipc/services/app_runtime_transport.ts", 1), diff --git a/src/hooks/useCoolifySetupSnapshot.test.tsx b/src/hooks/useCoolifySetupSnapshot.test.tsx new file mode 100644 index 0000000000..99553f2585 --- /dev/null +++ b/src/hooks/useCoolifySetupSnapshot.test.tsx @@ -0,0 +1,140 @@ +import { + QueryClient, + QueryClientProvider, + useQuery, +} from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { queryKeys } from "@/lib/queryKeys"; +import type { CoolifySetupState } from "@/coolify_setup/state"; + +const listeners = vi.hoisted( + () => ({ current: [] }) as { current: ((state: unknown) => void)[] }, +); +const snapshot = vi.hoisted(() => vi.fn()); +vi.mock("@/ipc/types", () => ({ + ipc: { + coolifySetup: { snapshot }, + events: { + coolifySetup: { + onChanged: (fn: (state: unknown) => void) => { + listeners.current.push(fn); + return () => { + listeners.current = listeners.current.filter((x) => x !== fn); + }; + }, + }, + }, + }, +})); + +const { useCoolifySetupSnapshot } = await import("./useCoolifySetupSnapshot"); + +const RUNNING: CoolifySetupState = { + type: "running", + host: "203.0.113.5", + invocationRef: { + kind: "coolify-setup", + entityKey: "203.0.113.5", + operationId: "op-1", + }, + step: "installing", + log: "", + stopping: false, +}; + +function push(state: unknown) { + act(() => { + for (const fn of listeners.current) fn(state); + }); +} + +function makeWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + return { client, Wrapper }; +} + +/** + * How many times the status query has been read. + * + * A run writes the account in the main process, so the status this panel + * already holds is stale from that moment — and staleTime keeps it. Counting + * reads is the only way to tell a refresh from a value that merely looks + * right because the test seeded it that way. + */ +function trackStatus() { + const calls = { count: 0 }; + const useStatus = () => + useQuery({ + queryKey: queryKeys.coolify.status({ appId: 1 }), + queryFn: () => { + calls.count += 1; + return Promise.resolve({ serverUrl: "http://203.0.113.5:8000" }); + }, + // As the panel holds it: read once, then kept until something says + // otherwise. Without this the refetch below would prove nothing. + staleTime: 60_000, + }); + return { calls, useStatus }; +} + +beforeEach(() => { + listeners.current = []; + snapshot.mockReset(); + snapshot.mockResolvedValue({ type: "idle" }); +}); + +describe("when a run settles", () => { + it("refreshes what the rest of the panel knows about this Coolify", async () => { + // The account is written partway through a run, in the main process. The + // only refresh was on the way out of the finished screen, which a failure + // never reaches, so the panel went on believing there was no server — + // and hid the sign-out that the refusal to install again asks for. + const { Wrapper } = makeWrapper(); + const { calls, useStatus } = trackStatus(); + renderHook( + () => { + useCoolifySetupSnapshot(); + useStatus(); + }, + { wrapper: Wrapper }, + ); + await waitFor(() => expect(calls.count).toBe(1)); + + push(RUNNING); + // Nothing is written until a run settles, so nothing is stale yet. + expect(calls.count).toBe(1); + + push({ + type: "failed", + host: "203.0.113.5", + message: "boom", + cancelled: false, + }); + await waitFor(() => expect(calls.count).toBe(2)); + }); + + it("refreshes it after one that finished, too", async () => { + const { Wrapper } = makeWrapper(); + const { calls, useStatus } = trackStatus(); + renderHook( + () => { + useCoolifySetupSnapshot(); + useStatus(); + }, + { wrapper: Wrapper }, + ); + await waitFor(() => expect(calls.count).toBe(1)); + + push({ type: "done", host: "203.0.113.5", result: { dashboardUrl: "x" } }); + + await waitFor(() => expect(calls.count).toBe(2)); + }); +}); diff --git a/src/hooks/useCoolifySetupSnapshot.ts b/src/hooks/useCoolifySetupSnapshot.ts new file mode 100644 index 0000000000..1fd3420281 --- /dev/null +++ b/src/hooks/useCoolifySetupSnapshot.ts @@ -0,0 +1,82 @@ +import { useEffect } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ipc } from "@/ipc/types"; +import { queryKeys } from "@/lib/queryKeys"; +import type { SetupSnapshot } from "@/ipc/types/coolify_setup"; + +/** + * Renderer binding for the Coolify setup machine. + * + * The snapshot is owned by the main process. Subscribing happens before the + * initial read so a run that finishes mid-mount is not missed, and a + * late-arriving read never overwrites a state already pushed. + * + * One hook rather than a query in each panel that wants it. Two components + * read this at once, and React Query runs whichever observer's queryFn it + * picked for the key — so a plain one anywhere is a plain one everywhere, + * and the guard below would be bypassed by the copy that did not have it. + */ +/** + * How many pushed states have landed, so a read can tell whether it was + * overtaken. Counted rather than flagged: the read compares against what it + * started with, and a refetch after an earlier event must not read as + * overtaken by that old one. + * + * Shared rather than held per caller. React Query keeps one set of options + * per key, taken from whichever observer last fetched — and that caller can + * unmount while another still reads the query, leaving a count that has + * stopped advancing to decide whether a read was overtaken. Only its changing + * matters, so several callers counting the same event is not a problem. + */ +let eventCount = 0; + +export function useCoolifySetupSnapshot() { + const queryClient = useQueryClient(); + + // Pushed rather than polled, so the step and the log keep up with a run + // this window did not start. + useEffect(() => { + return ipc.events.coolifySetup.onChanged((state) => { + eventCount += 1; + queryClient.setQueryData(queryKeys.coolify.setup, state); + // A run writes the account and the token in the main process, so what + // the rest of the panel believes about this Coolify is out of date the + // moment one settles. Only the way out of the finished screen refreshed + // it, which a failure never reaches — leaving the panel offering to + // install over a server whose password it is already holding. + if (state.type === "done" || state.type === "failed") { + // Everything about this Coolify except the snapshot itself, which + // was just handed to us. Refetching that would ask the main process + // to say again what it has already said, and a read still in flight + // from before is exactly how a finished run gets put back on a step + // it has left. + void queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === queryKeys.coolify.all[0] && + query.queryKey[1] !== queryKeys.coolify.setup[1], + }); + } + }); + }, [queryClient]); + + // What is going on is asked for, not remembered. An install outlives the + // screen — leaving it is invited, and a background refetch can replace it — + // so anything kept here would be lost exactly when it mattered. + return useQuery({ + queryKey: queryKeys.coolify.setup, + queryFn: async () => { + const before = eventCount; + const read = await ipc.coolifySetup.snapshot(); + // Overtaken while in flight. Answering with the read would put the + // panel back on a step the run has already left, and leave a Cancel + // button over a run that has finished until something refetches. + if (eventCount !== before) { + return ( + queryClient.getQueryData(queryKeys.coolify.setup) ?? + read + ); + } + return read; + }, + }); +} diff --git a/src/ipc/handlers/coolify_handlers.test.ts b/src/ipc/handlers/coolify_handlers.test.ts index 1fa14c8244..13504bd9cb 100644 --- a/src/ipc/handlers/coolify_handlers.test.ts +++ b/src/ipc/handlers/coolify_handlers.test.ts @@ -10,6 +10,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; */ const settings: Record = {}; + +/** The settings fixture is untyped, and these assertions are about secrets. */ +function storedCoolify() { + return (settings.coolify ?? {}) as { + accessToken?: { value: string }; + admin?: { email: string; password: { value: string }; instanceUrl: string }; + }; +} const rows: Record[] = []; vi.mock("../../main/settings", () => ({ @@ -220,6 +228,28 @@ describe("naming the stored token", () => { expect(JSON.stringify(status)).not.toContain("super-secret"); }); + it("names a server Dyad set up, so the panel can hold the address to it", async () => { + settings.coolify = { + ...(settings.coolify as Record), + admin: { + email: "me@gmail.com", + password: { value: "Abc123@xyz" }, + instanceUrl: "http://203.0.113.5:8000", + }, + }; + const status: any = await call("coolify:get-status", { appId: 1 }); + + expect(status.serverUrl).toBe("http://203.0.113.5:8000"); + // The address, and nothing that opens it. + expect(JSON.stringify(status)).not.toContain("Abc123@xyz"); + }); + + it("has no server address when Dyad set nothing up", async () => { + const status: any = await call("coolify:get-status", { appId: 1 }); + + expect(status.serverUrl).toBeNull(); + }); + it("is absent when there is no token", async () => { settings.coolify = { instanceUrl: "https://coolify.example.com" }; const status: any = await call("coolify:get-status", { appId: 1 }); @@ -240,6 +270,25 @@ describe("clearing the token", () => { expect(updateSet).not.toHaveBeenCalled(); }); + it("forgets every stored detail of the instance", async () => { + // Dyad holds one Coolify at a time, so anything surviving here belongs to + // an instance nothing is connected to — and the next connection would put + // a stranger's password under its address. The dialog in front of this + // shows all of it and asks the user to confirm before it goes. + settings.coolify = { + ...(settings.coolify as Record), + admin: { + email: "me@gmail.com", + password: { value: "Abc123@xyz" }, + instanceUrl: "https://coolify.example.com", + }, + }; + + await call("coolify:clear-token"); + + expect(storedCoolify()).toEqual({}); + }); + it("still reports the app as disconnected", async () => { await call("coolify:clear-token"); @@ -250,8 +299,9 @@ describe("clearing the token", () => { }; expect(status.hasToken).toBe(false); expect(status.connection).toBeNull(); - // Remembered so the token form does not make the user retype it. - expect(status.instanceUrl).toBe("https://coolify.example.com"); + // The address is part of the instance being forgotten, not a leftover + // convenience. It was on screen with a copy button on the way out. + expect(status.instanceUrl).toBeNull(); }); it("lets the same instance pick up where it left off", async () => { @@ -306,6 +356,41 @@ describe("clearing the token", () => { }); }); +describe("the admin account Dyad created", () => { + beforeEach(() => { + settings.coolify = { + ...(settings.coolify as Record), + admin: { + email: "me@gmail.com", + password: { value: "Abc123@xyz" }, + instanceUrl: "https://coolify.example.com", + }, + }; + }); + + it("survives the token for it being saved", async () => { + // Setting up a server writes the account before there is any token, and + // this is the call that supplies one. Replacing rather than merging here + // would drop the password on the way in, and Dyad has the only copy. + await call("coolify:save-token", { + instanceUrl: "https://coolify.example.com", + token: "tok-2", + acknowledgedInsecure: false, + }); + + expect(storedCoolify().admin?.email).toBe("me@gmail.com"); + expect(storedCoolify().admin?.password.value).toBe("Abc123@xyz"); + }); + + it("goes when the instance is forgotten", async () => { + // The account only opens the instance being signed out of, so keeping it + // would leave a password for a Coolify nothing is connected to. + await call("coolify:clear-token"); + + expect(storedCoolify().admin).toBeUndefined(); + }); +}); + describe("moving an app to a different server or project", () => { it("releases the application, which cannot move with it", async () => { // Coolify cannot move an application between servers, so keeping its id diff --git a/src/ipc/handlers/coolify_handlers.ts b/src/ipc/handlers/coolify_handlers.ts index d5039eb97a..06b53e99f6 100644 --- a/src/ipc/handlers/coolify_handlers.ts +++ b/src/ipc/handlers/coolify_handlers.ts @@ -1,12 +1,13 @@ import { BrowserWindow } from "electron"; import { eq } from "drizzle-orm"; import log from "electron-log"; -import * as dns from "node:dns/promises"; import { createHash } from "node:crypto"; import { db } from "../../db"; import { apps } from "../../db/schema"; +import { resolveBoth } from "../utils/dns_resolve"; import { readSettings, writeSettings } from "../../main/settings"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { forgottenCoolify } from "@/lib/schemas"; import { getClient, readConnectionState, @@ -26,7 +27,7 @@ import { isLoopbackAddress, isNonRoutableAddress, isDeferredIpv6, -} from "@/coolify_deploy/domain_check"; +} from "@/shared/domain_check"; import { applyCoolifyConnectionChange, isHostMove, @@ -47,56 +48,6 @@ async function getApp(appId: number) { return app; } -/** A resolver saying "no such record" — anything else is our problem, not DNS's. */ -const NO_RECORD_CODES = new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]); - -/** - * Bounded, because Save waits on it. - * - * The check is advisory, so a resolver that never answers must not be able to - * hold the button indefinitely. The bound is per attempt per configured - * nameserver, so the wait scales with how many the machine has: six seconds - * against a single stub resolver, and proportionally more where several are - * listed. Two tries rather than the default four keeps that multiple small. A - * timeout still arrives as an error code the caller reads as "could not ask" - * rather than as a missing record. - */ -const resolver = new dns.Resolver({ timeout: 3_000, tries: 2 }); - -/** - * Both families, distinguishing "no record" from "could not ask". - * - * A timeout or an unreachable resolver must not be reported as a missing - * record: telling someone to fix DNS that is already correct is exactly the - * confident-but-wrong advice the unknown verdict exists to avoid. - */ -async function resolveBoth( - hostname: string, -): Promise<{ addresses: string[]; failed: boolean }> { - const attempt = async (fn: (h: string) => Promise) => { - try { - return { addresses: await fn(hostname), failed: false }; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code ?? ""; - return { addresses: [] as string[], failed: !NO_RECORD_CODES.has(code) }; - } - }; - const [v4, v6] = await Promise.all([ - attempt((h) => resolver.resolve4(h)), - attempt((h) => resolver.resolve6(h)), - ]); - return { - addresses: [...v4.addresses, ...v6.addresses], - // One family answering is enough. But a definitive "no such record" from - // one and a failed lookup from the other is not the same as no records: - // the family we could not reach may hold the one that works. - failed: - v4.addresses.length === 0 && - v6.addresses.length === 0 && - (v4.failed || v6.failed), - }; -} - /** The app's stored connection, or nothing when it has none. */ function readConnection( state: CoolifyConnectionState, @@ -152,6 +103,7 @@ export function registerCoolifyHandlers() { hasToken: Boolean(token), tokenId: token ? tokenFingerprint(token) : null, instanceUrl: settings.coolify?.instanceUrl ?? null, + serverUrl: settings.coolify?.admin?.instanceUrl ?? null, connection: readConnection(state), appUrl: deployed?.appUrl ?? null, lastDeployedAt: deployed?.lastDeployedAt.getTime() ?? null, @@ -177,8 +129,9 @@ export function registerCoolifyHandlers() { await probe.listServers(); const normalized = instanceUrl.replace(/\/+$/, ""); const previous = readSettings().coolify?.instanceUrl; - // Spread, as clearToken does: a field added to CoolifySchema later - // should not be dropped by whichever of the two happens to run. + // Spread: the admin account for a server Dyad just installed is set + // before there is any token for it, and this is the call that supplies + // the token. Replacing would drop the password on the way in. writeSettings({ coolify: { ...readSettings().coolify, @@ -210,22 +163,26 @@ export function registerCoolifyHandlers() { }); createTypedHandler(coolifyContracts.clearToken, async () => { - // Only the token. Every app reads as disconnected without one, so there is - // nothing to gain by clearing their rows — and doing so would throw away - // each app's Coolify application id, which is the one value that cannot be - // re-entered. The next deploy would then build a second application beside - // the one already running and lose a fight with it over the domain. + // Every field, not just the token. Dyad holds one Coolify at a time, so + // an address or an admin account left behind belongs to an instance + // nothing is connected to any more — and the next connection would put a + // stranger's password under its address. The screen that reaches this + // shows all of it one last time and asks the user to confirm, because + // Dyad invented the password and is the only thing that has it. + // + // Every field named rather than an empty object: an absent key reads to + // writeSettings as one a consumer read could not decrypt, and it hands + // the ciphertext back — so an empty coolify would return the token this + // is here to forget. forgottenCoolify names them all and stops + // compiling when CoolifySchema grows one more. // - // The instance URL stays so that saveToken can still tell whether the next - // token points somewhere else. Even then the rows are kept: those ids name - // applications still running on the old instance, and nothing in Dyad - // could reach them again once they were gone. + // The apps' rows are not touched. They read as disconnected without a + // token anyway, and they carry each app's Coolify application id — the + // one value that cannot be typed back in. Losing it would make the next + // deploy build a second application beside the one already running and + // lose a fight with it over the domain. coolifyDeployRegistry.cancelAll(); - // The address survives; only the token goes. Spread rather than replaced, - // so a field added to CoolifySchema later is not silently dropped here. - writeSettings({ - coolify: { ...readSettings().coolify, accessToken: undefined }, - }); + writeSettings({ coolify: forgottenCoolify() }); }); createTypedHandler(coolifyContracts.createProject, async (_, { name }) => { diff --git a/src/ipc/handlers/coolify_setup_handlers.test.ts b/src/ipc/handlers/coolify_setup_handlers.test.ts new file mode 100644 index 0000000000..0767867c7d --- /dev/null +++ b/src/ipc/handlers/coolify_setup_handlers.test.ts @@ -0,0 +1,1141 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +// The mocked class, so the handler recognises what it is handed. +import { SshError } from "../utils/ssh_client"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { + SETUP_MACHINE_REPORTED, + SetupResultSchema, +} from "@/ipc/types/coolify_setup"; + +const h = vi.hoisted(() => ({ + settings: {} as Record, + written: [] as Array>, + serverKey: { publicKey: "ssh-ed25519 AAAAPUB dyad", privateKey: "PRIVATE" }, + setupResult: null as unknown, + setupError: null as unknown, + lastSetupOptions: null as Record | null, + sessionEnded: 0, + reportsAccount: true, + runCalls: 0, + verifiedAgainst: [] as string[], + writeThrows: false, + /** How many writes fail before the store comes back. */ + writeFailures: 0, + /** + * Writes that succeed before the failures above start. + * + * The record written on the way in is the first write, and failing it now + * refuses the run outright — so a case about the writes that come after the + * account exists has to let that one through. + */ + writeOkFirst: 0, + reportsAccountTwice: false, + failsBeforeCredentials: false, + onRunStarted: null as null | (() => void), + preflightThrows: false, + preflightReady: true, + fingerprint: "SHA256:fingerprint", + lastConnectTarget: null as null | { host: string }, + /** What the SSH connect throws, for the cases about a failed connect. */ + connectError: null as unknown, +})); + +vi.mock("electron", () => ({ BrowserWindow: { getAllWindows: () => [] } })); + +const handlers = new Map Promise>(); +vi.mock("./base", () => ({ + createTypedHandler: ( + contract: { channel: string }, + handler: (...args: unknown[]) => Promise, + ) => handlers.set(contract.channel, handler), +})); + +vi.mock("@/main/settings", () => ({ + readSettings: () => h.settings, + writeSettings: (value: Record) => { + if (h.writeOkFirst > 0) { + h.writeOkFirst -= 1; + h.written.push(value); + Object.assign(h.settings, value); + return; + } + if (h.writeFailures > 0) { + h.writeFailures -= 1; + throw new Error("keychain is unavailable"); + } + if (h.writeThrows) throw new Error("keychain is unavailable"); + h.written.push(value); + Object.assign(h.settings, value); + }, +})); + +vi.mock("@/coolify_setup/server_key", () => ({ + ensureServerKey: () => h.serverKey, +})); + +vi.mock("../utils/ssh_client", () => ({ + // The real client runs the verifier during the handshake, which is what + // reports the fingerprint. A mock that skipped it would leave the handler + // looking correct while reporting nothing. + connectSsh: vi.fn( + async (target: { host: string }, verify: (fp: string) => boolean) => { + h.lastConnectTarget = target; + if (h.connectError) throw h.connectError; + verify(h.fingerprint); + return { + run: vi.fn(), + end: () => { + h.sessionEnded += 1; + }, + }; + }, + ), + trustOnFirstUse: (onSeen: (fp: string) => void) => (fingerprint: string) => { + onSeen(fingerprint); + return true; + }, + // Recorded where it is built, not where it is called: the flow is mocked + // here, so what this proves is which verifier the handler chose. + expectFingerprint: (expected: string) => { + h.verifiedAgainst.push(expected); + return (fingerprint: string) => fingerprint === expected; + }, + // Close enough to the real class for what this file asserts: the failure, + // the kind, the errno, and the name `sshFailureOf` matches on. It is not a DyadError, + // so a case about what survives serialization would need more than this. + // + // The failure and the kind are asserted on for opposite reasons. The + // failure is read here and goes no further: it is what drops an unreachable server from + // telemetry, as the user's own network rather than a fault here, and the + // serialized error has no such field. The kind is not read for this + // failure — the filter has already answered on the failure — and it does + // cross. Between them they pin the error as the one the client threw. + SshError: class SshError extends Error { + constructor( + readonly failure: string, + message: string, + readonly kind?: string, + readonly systemCode?: string, + ) { + super(message); + this.name = "SshError"; + } + }, +})); + +vi.mock("@/coolify_setup/install", () => ({ + preflight: vi.fn(async () => { + if (h.preflightThrows) throw new Error("docker never answered"); + return { + ready: h.preflightReady, + reason: h.preflightReady ? undefined : "It already has Coolify on it.", + alreadyInstalled: !h.preflightReady, + memoryMb: 1967, + }; + }), +})); + +vi.mock("@/coolify_setup/setup_flow", () => ({ + runServerSetup: vi.fn(async (options: Record) => { + h.runCalls += 1; + h.lastSetupOptions = options; + // Connecting and the preflight both come before the password is handed + // over, and either can end the run. + if (h.failsBeforeCredentials) throw h.setupError; + // The real flow hands over the password before the installer runs, since + // it invented it rather than discovering it. + ( + options.onCredentialsBuilt as (a: { + credentials: { email: string; password: string }; + dashboardUrl: string; + }) => void + )({ + credentials: { email: "me@gmail.com", password: "Abc123@xyz" }, + dashboardUrl: "http://203.0.113.5:8000", + }); + // The real flow reports the account the moment it exists, before the + // steps that can still fail. + if (h.reportsAccount) { + ( + options.onAccountKnown as (a: { + credentials: { email: string; password: string }; + dashboardUrl: string; + }) => void + )({ + credentials: { email: "me@gmail.com", password: "Abc123@xyz" }, + dashboardUrl: "http://203.0.113.5:8000", + }); + // And again once HTTPS has settled the address it is reachable at. + if (h.reportsAccountTwice) { + ( + options.onAccountKnown as (a: { + credentials: { email: string; password: string }; + dashboardUrl: string; + }) => void + )({ + credentials: { email: "me@gmail.com", password: "Abc123@xyz" }, + dashboardUrl: "https://203.0.113.5.sslip.io", + }); + } + } + // Something else writing while the run is in flight. + h.onRunStarted?.(); + if (h.setupError) throw h.setupError; + return h.setupResult; + }), +})); + +const { registerCoolifySetupHandlers, resetCoolifySetupStateForTests } = + await import("./coolify_setup_handlers"); + +/** Install requires a check first, so this is what "run it" means now. */ +async function checkThenRun(input: Record = TARGET) { + await call("coolify-setup:inspect", input); + return call("coolify-setup:run", input); +} + +function call(channel: string, input?: unknown) { + const handler = handlers.get(channel); + if (!handler) throw new Error(`No handler for ${channel}`); + return handler({}, input); +} + +const TARGET = { + host: "203.0.113.5", + username: "root", + adminEmail: "me@gmail.com", +}; + +const RESULT = { + dashboardUrl: "http://203.0.113.5:8000", + // The ordinary end of a run: a certificate arrived. Named rather than left + // out, because a token for an address that is not encrypted is held for the + // user to agree to rather than stored, and omitting this reads as that. + secure: true, + credentials: { + username: "dyad-admin", + email: "me@gmail.com", + password: "Abc123@xyz", + }, + token: "1|abc", + // A token comes from a mint, and Dyad opens the API to reach one. + apiEnabled: true, + version: "4.3.2", +}; + +beforeEach(() => { + handlers.clear(); + h.settings = {}; + h.written.length = 0; + h.setupResult = RESULT; + h.setupError = null; + h.lastSetupOptions = null; + h.sessionEnded = 0; + h.reportsAccount = true; + h.failsBeforeCredentials = false; + h.lastConnectTarget = null; + h.connectError = null; + h.onRunStarted = null; + h.runCalls = 0; + h.verifiedAgainst.length = 0; + h.writeThrows = false; + h.writeFailures = 0; + h.writeOkFirst = 0; + h.reportsAccountTwice = false; + h.preflightThrows = false; + h.preflightReady = true; + h.fingerprint = "SHA256:fingerprint"; + resetCoolifySetupStateForTests(); + registerCoolifySetupHandlers(); +}); + +describe("getServerKey", () => { + it("hands over only the public half", async () => { + // The private key is what reaches the user's server; it belongs in the + // main process, exactly like the API token. + const result = (await call("coolify-setup:get-server-key")) as Record< + string, + unknown + >; + expect(result).toEqual({ publicKey: h.serverKey.publicKey }); + expect(JSON.stringify(result)).not.toContain("PRIVATE"); + }); +}); + +describe("inspect", () => { + it("reports the fingerprint it saw", async () => { + const result = (await call("coolify-setup:inspect", TARGET)) as Record< + string, + unknown + >; + expect(result.hostFingerprint).toBe("SHA256:fingerprint"); + expect(result.ready).toBe(true); + }); + + it("closes the connection it opened", async () => { + await call("coolify-setup:inspect", TARGET); + expect(h.sessionEnded).toBe(1); + }); + + /** + * What the client reports for a name that does not resolve. + * + * Written out rather than shortened: a connect the socket reports as failed + * does not arrive raw — `classify` turns those into an SshError with words + * of its own — so a fixture shaped like the library's own message would be + * a shape this path does not produce, and would let the hint be built + * against text the user never sees. + * + * A function rather than a constant, because the handler rewrites the + * message of the error it is given: one shared instance would carry the + * previous case's hint into the next one. + */ + const UNREACHABLE = () => + new SshError( + "unreachable", + "Could not reach the server (ENOTFOUND). Check the address and that " + + "port 22 is open.", + DyadErrorKind.External, + "ENOTFOUND", + ); + + it("says what to type instead, before anything else", async () => { + // The other way in — a token for a Coolify that already exists — asks for + // exactly that shape, so it is the likeliest wrong answer. What the client + // says on its own names the address as one of two suspects; the other is a + // port nobody is listening on, and that is the one people go and look at. + h.connectError = UNREACHABLE(); + + await expect( + call("coolify-setup:inspect", { + ...TARGET, + host: "https://203.0.113.5:8000", + }), + ).rejects.toThrow(/^Enter just the server address[\s\S]*ENOTFOUND/); + }); + + it("says it for an address with a path on it, too", async () => { + // The half with no scheme in it — an address someone put a path onto, + // copied out of a page that documented one. Worth its own case because + // this is where calling it "a URL" would have been a guess: the message + // states the rule instead, so it is true of both. + h.connectError = UNREACHABLE(); + + await expect( + call("coolify-setup:inspect", { ...TARGET, host: "203.0.113.5/coolify" }), + ).rejects.toThrow(/Enter just the server address/); + }); + + it("keeps the fault it was given, and what it was", async () => { + // Added to the error rather than replacing it. The code is the one part + // of what the client said that a bug report needs — the sentence around + // it offers a closed port, which the hint has just ruled out — and the + // failure decides, in this process before any of it is serialized, + // whether this is reported at all. + h.connectError = UNREACHABLE(); + + await expect( + call("coolify-setup:inspect", { ...TARGET, host: "203.0.113.5/coolify" }), + ).rejects.toMatchObject({ + failure: "unreachable", + kind: DyadErrorKind.External, + // Ends there: the sentence the client wrapped the code in offers a + // closed port as the other suspect, and keeping it would put a second + // answer under the one this just gave. + message: expect.stringMatching(/\(ENOTFOUND\)$/), + }); + }); + + it("falls back to the failure when the system named nothing", async () => { + // Only some of `classify`'s branches have an errno to pass on. The rest + // still carry a failure, which is a word rather than the sentence they + // wrote — and that sentence is the one offering a closed port, which the + // instruction it is appended to has just ruled out. + h.connectError = new SshError( + "timeout", + "The server did not answer in time. Check the address and that port " + + "22 is reachable.", + DyadErrorKind.External, + ); + + await expect( + call("coolify-setup:inspect", { ...TARGET, host: "203.0.113.5/coolify" }), + ).rejects.toThrow(/in it\. \(timeout\)$/); + }); + + it("says nothing about the address once the connection is open", async () => { + // Only the connect is covered, and nothing but this says so. Past it the + // address reached something, so what failed is the server — and the hint + // would be advice the connection that just succeeded has disproved. + h.preflightThrows = true; + + await expect( + call("coolify-setup:inspect", { ...TARGET, host: "203.0.113.5/coolify" }), + ).rejects.toThrow(/^docker never answered$/); + }); + + it("says only the instruction when the error carries neither", async () => { + // Nothing to add is not a reason to add the message back: what is left + // is the one sentence that tells the user what to do. + h.connectError = new Error("something went wrong"); + + await expect( + call("coolify-setup:inspect", { ...TARGET, host: "203.0.113.5/coolify" }), + ).rejects.toThrow(/no \/ characters in it\.$/); + }); + + it("says nothing about an address it does not recognise", async () => { + // The whole point of adding this after the failure rather than before the + // connect. An address shaped like nothing in particular — a single-label + // name, an IPv6 literal — is none of its business, and a hint here would + // be a confident wrong answer on top of a real fault. + // + // Given the same failure as the cases that do get the hint, so the + // address is the only thing separating them from this one: a plainer + // error here would let an implementation that hints on every SshError + // pass this untouched. + for (const host of ["fe80::1", "coolify", "my_server.local"]) { + h.connectError = UNREACHABLE(); + await expect( + call("coolify-setup:inspect", { ...TARGET, host }), + ).rejects.toThrow(/^Could not reach the server \(ENOTFOUND\)\./); + } + }); +}); + +describe("run", () => { + it("refuses an address Coolify will not accept, before doing anything", async () => { + // Its seeder resolves the domain. Finding out afterwards costs the whole + // install and leaves an instance with no account on it. + await expect( + call("coolify-setup:run", { ...TARGET, adminEmail: "admin@dyad.test" }), + ).rejects.toMatchObject({ kind: "validation" }); + }); + + it("holds the install to the identity the inspection saw", async () => { + // The panel shows that fingerprint and asks the user to commit minutes to + // it. Without the pin, the install accepts whatever answers the address by + // the time it starts. + await checkThenRun(); + + expect(h.verifiedAgainst).toContain("SHA256:fingerprint"); + }); + + it("does not hold one port to what another on the same address showed", async () => { + // Two services on one address are two servers. Keyed by address alone, + // the second is checked against the first one's fingerprint and refused. + await call("coolify-setup:inspect", { ...TARGET, port: 22 }); + + await expect( + call("coolify-setup:run", { ...TARGET, port: 2222 }), + ).rejects.toThrow(/Check the server/); + }); + + it("does not hold one server to what another one showed", async () => { + // Addresses are remembered as themselves. Read as URLs, everything shaped + // like fe80::1 parses as a scheme with no hostname and shares one entry, + // so a second server would be refused for the first one's key. + await call("coolify-setup:inspect", { ...TARGET, host: "fe80::1" }); + + await expect( + call("coolify-setup:run", { ...TARGET, host: "fe80::2" }), + ).rejects.toThrow(/Check the server/); + }); + + it("says the identity changed rather than reporting a cancellation", async () => { + // host-key-rejected is how a user declining is reported too, and that + // reads as "nothing happened" — which is the wrong thing to say when a + // server has been swapped underneath the address. + await call("coolify-setup:inspect", TARGET); + h.setupError = new SshError( + "host-key-rejected", + "The server's identity was not accepted, so nothing was sent to it.", + DyadErrorKind.UserCancelled, + ); + + await expect(checkThenRun()).rejects.toThrow(/identity has changed/); + }); + + it("does not start an install it cannot record the password for", async () => { + // Before the installer, so nothing has been done to the server and this + // costs a retry. Carrying on would put an account on a machine whose + // password Dyad never managed to keep — and preflight then refuses to + // install again, so there is no way back to it. + h.writeThrows = true; + + const error = (await checkThenRun().catch((e: unknown) => e)) as Error; + + expect(error.message).toMatch(/could not save the admin/); + // What the keychain said is logged, not handed to a screen that already + // carries a password this run invented. + expect(error.message).not.toMatch(/Abc123@xyz/); + expect(error.message).not.toMatch(/keychain is unavailable/); + // And nothing was left behind to hold up the next attempt. + expect( + (h.settings.coolify as { admin?: unknown } | undefined)?.admin, + ).toBeUndefined(); + }); + + it("finishes when the account cannot be written down", async () => { + // The account is on the server either way, and a retry is refused because + // Coolify is installed now — so ending the run here would lose the only + // copy of a password Dyad invented. The record on the way in lands: that + // one failing refuses the run instead, before anything is installed. + h.writeOkFirst = 1; + h.writeThrows = true; + + const result = (await checkThenRun()) as { + adminPassword: string; + tokenStored: boolean; + tokenUnavailableReason: string; + }; + + expect(result.adminPassword).toBe("Abc123@xyz"); + // Nothing was written, so the next screen has no token to use — saying + // otherwise sends the user to a panel that cannot work. + expect(result.tokenStored).toBe(false); + // Where the password actually is. The screen puts the card above this + // message, and on this path it is the only copy — so the direction is + // the part that matters, and it is written here rather than there. + expect(result.tokenUnavailableReason).toContain("could not save"); + expect(result.tokenUnavailableReason).toContain("password above"); + }); + + it("refuses a server it has not looked at", async () => { + // The form disables Install until the check has run, but this is the call + // that sends the credentials, so it says no on its own account. + await expect(call("coolify-setup:run", TARGET)).rejects.toThrow( + /Check the server/, + ); + }); + + it("refuses a server whose check never finished", async () => { + // Neither the key nor the pass is recorded until a check has finished, so + // a connection that opened leaves nothing for an install to go on. + h.preflightThrows = true; + await expect(call("coolify-setup:inspect", TARGET)).rejects.toThrow(); + + await expect(call("coolify-setup:run", TARGET)).rejects.toThrow( + /Check the server/, + ); + expect(h.runCalls).toBe(0); + }); + + it("refuses a server the check turned down", async () => { + h.preflightReady = false; + await call("coolify-setup:inspect", TARGET); + + await expect(call("coolify-setup:run", TARGET)).rejects.toThrow( + /Check the server/, + ); + expect(h.runCalls).toBe(0); + }); + + it("keeps the answer that stands when a re-check does not finish", async () => { + // The handshake happens before preflight, so recording the key there left + // the new machine's key beside the old machine's pass — an install onto a + // server whose check never came back. + await call("coolify-setup:inspect", TARGET); + + // A different machine answers the address, and its check does not finish. + h.fingerprint = "SHA256:someone-else"; + h.preflightThrows = true; + await expect(call("coolify-setup:inspect", TARGET)).rejects.toThrow(); + h.preflightThrows = false; + + // The pass from the finished check still stands, and it is still paired + // with the key that check saw — not with the one nobody approved. + await call("coolify-setup:run", TARGET); + expect(h.verifiedAgainst).toEqual(["SHA256:fingerprint"]); + }); + + it("drops a pass the next check takes back", async () => { + // A server that was ready and has since had Coolify put on it is not one + // to install onto, and the second answer is the true one. + await call("coolify-setup:inspect", TARGET); + h.preflightReady = false; + await call("coolify-setup:inspect", TARGET); + + await expect(call("coolify-setup:run", TARGET)).rejects.toThrow( + /Check the server/, + ); + }); + + it("leaves the one-at-a-time refusal unmarked", async () => { + // That refusal comes from the machine declining to start, not from a run + // it took on — so it has nothing on screen of its own, and the panel has + // to say it. What keeps it unmarked is where start() sits. + h.reportsAccount = false; + let release!: () => void; + h.setupResult = new Promise((resolve) => { + release = () => resolve(RESULT); + }); + const first = checkThenRun(); + + await expect(checkThenRun()).rejects.not.toMatchObject({ + code: SETUP_MACHINE_REPORTED, + }); + + release(); + await first; + }); + + it("leaves an error that carries its own code alone", async () => { + // A system error names itself — ENOTFOUND and the like — and overwriting + // that loses what went wrong. Said twice is better than said wrongly. + h.setupError = Object.assign(new Error("getaddrinfo ENOTFOUND"), { + code: "ENOTFOUND", + }); + + await expect(checkThenRun()).rejects.toMatchObject({ code: "ENOTFOUND" }); + }); + + it("stores the account on the way out when the first attempt failed", async () => { + // Coolify has the account either way, and preflight refuses to install + // over it — so a password stored nowhere is a server nobody can sign into. + // The record on the way in lands — failing that refuses the run — and + // the account's own write is the one that does not, so the retry runs. + h.writeOkFirst = 1; + h.writeFailures = 1; + h.reportsAccount = true; + h.setupError = new DyadError("exit 1", DyadErrorKind.External); + + await expect(checkThenRun()).rejects.toThrow("exit 1"); + + const saved = h.written.at(-1) as { + coolify: { admin: { password: { value: string } } }; + }; + expect(saved.coolify.admin.password.value).toBe("Abc123@xyz"); + }); + + it("does not put back an address a later write replaced", async () => { + // The account is reported twice — once when it exists, and again once + // HTTPS has settled where it answers. A copy kept from the first would + // write the earlier address back over the later one on the way out. + // The record on the way in lands, and the first of the two accounts does + // not — so the copy left behind is the one holding the old address. + h.writeOkFirst = 1; + h.writeFailures = 1; + h.reportsAccount = true; + h.reportsAccountTwice = true; + h.setupError = new DyadError("exit 1", DyadErrorKind.External); + + await expect(checkThenRun()).rejects.toThrow("exit 1"); + + const saved = h.written.at(-1) as { + coolify: { admin: { instanceUrl: string } }; + }; + expect(saved.coolify.admin.instanceUrl).toBe( + "https://203.0.113.5.sslip.io", + ); + }); + + it("reports what went wrong, not what the retry did", async () => { + // A write that fails again must not become the failure the user is told + // about — the install is what they were watching. The record on the way + // in lands, since failing that refuses the run before it starts. + h.writeOkFirst = 1; + h.writeThrows = true; + h.reportsAccount = true; + h.setupError = new DyadError("exit 1", DyadErrorKind.External); + + await expect(checkThenRun()).rejects.toThrow("exit 1"); + }); + + it("marks a failure the machine already put on screen", async () => { + // The panel suppresses what carries this and shows everything else, so + // the mark is what stops one failure being reported twice. + h.setupError = new DyadError("exit 1", DyadErrorKind.External); + + await expect(checkThenRun()).rejects.toMatchObject({ + code: SETUP_MACHINE_REPORTED, + }); + }); + + it("leaves a refusal that never started unmarked", async () => { + // Nothing reached the machine, so nothing is on screen — and an unmarked + // error is the one the panel says out loud. + await expect(call("coolify-setup:run", TARGET)).rejects.not.toMatchObject({ + code: SETUP_MACHINE_REPORTED, + }); + }); + + it("stores the token it minted", async () => { + await checkThenRun(); + const saved = h.written.at(-1) as { + coolify: { accessToken: { value: string }; instanceUrl: string }; + }; + expect(saved.coolify.accessToken.value).toBe("1|abc"); + expect(saved.coolify.instanceUrl).toBe("http://203.0.113.5:8000"); + }); + + it("stores the admin password, so the user is not locked out later", async () => { + // Dyad invented this password for a machine the user owns. Storing the + // token but not this leaves them unable to sign in to their own server. + await checkThenRun(); + const saved = h.written.at(-1) as { + coolify: { admin?: { password: { value: string }; email: string } }; + }; + expect(saved.coolify.admin?.password.value).toBe("Abc123@xyz"); + expect(saved.coolify.admin?.email).toBe("me@gmail.com"); + }); + + it("records which instance the account is on", async () => { + // Connecting Dyad to a different Coolify later has to know this account + // does not come along. + await checkThenRun(); + const saved = h.written.at(-1) as { + coolify: { admin?: { instanceUrl: string } }; + }; + expect(saved.coolify.admin?.instanceUrl).toBe("http://203.0.113.5:8000"); + }); + + it("returns the password so it can be shown once", async () => { + const result = (await checkThenRun()) as Record; + expect(result.adminPassword).toBe("Abc123@xyz"); + expect(result.tokenStored).toBe(true); + }); + + it("answers in the shape the channel says it will", async () => { + // These cases mock the wrapper that parses this on the way out, so + // nothing else here would notice the answer drifting from the contract, + // or the fixture above drifting from what the flow now returns. + const result = await checkThenRun(); + expect(SetupResultSchema.safeParse(result)).toMatchObject({ + success: true, + }); + }); + + it("keeps the install when no token could be created", async () => { + h.setupResult = { + ...RESULT, + token: null, + tokenUnavailableReason: "too old", + }; + const result = (await checkThenRun()) as Record; + + expect(result.tokenStored).toBe(false); + expect(result.tokenUnavailableReason).toBe("too old"); + expect(result.adminPassword).toBe("Abc123@xyz"); + + // The account is kept even though the token is not: this is the one case + // where the user has to sign in to Coolify themselves, so throwing the + // password away here would take away the only way to do it. + const saved = h.written.at(-1) as { + coolify: { + admin?: { password: { value: string } }; + accessToken?: unknown; + instanceUrl?: string; + }; + }; + expect(saved.coolify.admin?.password.value).toBe("Abc123@xyz"); + // No token and no address, because there is no instance Dyad can talk to. + expect(saved.coolify.accessToken).toBeUndefined(); + expect(saved.coolify.instanceUrl).toBeUndefined(); + }); + + it("keeps the password when the install fails after the account exists", async () => { + // The dashboard never answering does not un-create the account. Dyad is + // the only thing that knows the password it invented, so failing here + // without storing it locks the user out of a server that is running. + h.setupError = new Error( + "Coolify was installed but its dashboard did not start.", + ); + await checkThenRun().catch(() => {}); + + const saved = h.written.at(-1) as { + coolify: { + admin?: { password: { value: string }; instanceUrl: string }; + }; + }; + expect(saved.coolify.admin?.password.value).toBe("Abc123@xyz"); + expect(saved.coolify.admin?.instanceUrl).toBe("http://203.0.113.5:8000"); + }); + + it("leaves no account behind for a server that never got one", async () => { + // The password goes down before the installer runs, so a run that ends + // without ever seeding an account has to take it back off — it opens + // nothing, and leaving it would stand in the way of installing again. + h.reportsAccount = false; + h.setupError = new Error("This server cannot be set up automatically."); + await checkThenRun().catch(() => {}); + + const saved = h.written.at(-1) as { coolify: { admin?: unknown } }; + expect(saved.coolify.admin).toBeUndefined(); + }); + + it("has the password down before the installer is finished with it", async () => { + // The installer writes it into Coolify's own .env partway through a run + // that takes minutes. Quitting in between is what loses the only copy. + h.reportsAccount = false; + h.setupError = new Error("boom"); + await checkThenRun().catch(() => {}); + + const early = h.written[0] as { + coolify: { admin?: { password?: { value: string } } }; + }; + expect(early.coolify.admin?.password?.value).toBeTruthy(); + }); + + it("hands ssh2 an address it recognises, brackets or not", async () => { + // [2001:db8::1] is how documentation writes a v6 literal, and how anyone + // would paste one. ssh2 reads the brackets as part of a hostname and + // looks it up, so a reachable server reports as unreachable. + await call("coolify-setup:inspect", { + ...TARGET, + host: "[2001:db8::1]", + }); + + expect(h.lastConnectTarget?.host).toBe("2001:db8::1"); + }); + + it("does not put back a record something else replaced mid-run", async () => { + // Minutes of installing sit between the record going down and the way + // out. Signing out in another window during that time is a newer answer + // than anything this run knows, and clearing on the way out would write + // over whatever that left. + h.reportsAccount = false; + h.setupError = new Error("boom"); + h.onRunStarted = () => { + // As another window connecting to a Coolify would leave it. The admin + // record matters more than the token: clearing on the way out spreads + // what it read and names only admin, so a token would survive either + // way and prove nothing about the guard. + h.settings.coolify = { + instanceUrl: "https://elsewhere.example.com", + accessToken: { value: "1|theirs" }, + admin: { + email: "other@gmail.com", + password: { value: "Other123@xyz" }, + instanceUrl: "https://elsewhere.example.com", + }, + }; + }; + await checkThenRun().catch(() => {}); + + // Untouched: what is there is newer than anything this run knows, and it + // is the only copy of that server's password. + const coolify = h.settings.coolify as { + accessToken?: { value: string }; + admin?: { password?: { value: string } }; + }; + expect(coolify.accessToken?.value).toBe("1|theirs"); + expect(coolify.admin?.password?.value).toBe("Other123@xyz"); + }); + + it("takes its own record back off even if the keychain relocked meanwhile", async () => { + // readSettings drops a password it cannot decrypt and keeps the account, + // so this run's own record comes back without one. That is it gone + // unreadable rather than somebody else's writing — and leaving it behind + // holds a password that opens nothing, which is what refuses the next + // install. + h.reportsAccount = false; + h.setupError = new Error("boom"); + h.onRunStarted = () => { + const coolify = h.settings.coolify as { admin?: Record }; + expect(coolify.admin).toBeTruthy(); + delete coolify.admin!.password; + }; + await checkThenRun().catch(() => {}); + + expect( + (h.settings.coolify as { admin?: unknown } | undefined)?.admin, + ).toBeUndefined(); + }); + + it("refuses a second setup on a different machine", async () => { + // Two installs at once would interleave their output, and the second + // machine's run has nothing to do with the first's. + h.reportsAccount = false; + let release!: () => void; + h.setupResult = new Promise((resolve) => { + release = () => resolve(RESULT); + }); + const first = checkThenRun(); + // No account seeded by the first run, so the refusal below is the + // one-at-a-time rule rather than the gate that asks for a check or the + // one that holds an account — all three refuse the same way. + await call("coolify-setup:inspect", { ...TARGET, host: "198.51.100.7" }); + await expect( + call("coolify-setup:run", { ...TARGET, host: "198.51.100.7" }), + ).rejects.toThrow(/already being set up/); + release(); + await first; + }); + + it("refuses a second setup on the same machine too", async () => { + // Nobody needs to press Install to get back to a run any more: the panel + // asks what is going on and shows it. So a second press is a genuine + // second request, and two installs on one machine would fight. + h.reportsAccount = false; + let release!: () => void; + h.setupResult = new Promise((resolve) => { + release = () => resolve(RESULT); + }); + const first = checkThenRun(); + await expect(checkThenRun()).rejects.toMatchObject({ + kind: "precondition", + }); + release(); + await first; + }); + + it("hands back what is going on, so a panel can show it", async () => { + h.reportsAccount = false; + let release!: () => void; + h.setupResult = new Promise((resolve) => { + release = () => resolve(RESULT); + }); + await call("coolify-setup:inspect", TARGET); + const running = call("coolify-setup:run", TARGET); + + const snapshot = (await call("coolify-setup:snapshot")) as { + type: string; + host: string; + }; + expect(snapshot.type).toBe("running"); + expect(snapshot.host).toBe("203.0.113.5"); + + release(); + await running; + expect( + ((await call("coolify-setup:snapshot")) as { type: string }).type, + ).toBe("done"); + }); + + it("puts the finished screen away when the user moves on", async () => { + await checkThenRun(); + await call("coolify-setup:dismiss"); + + expect( + ((await call("coolify-setup:snapshot")) as { type: string }).type, + ).toBe("idle"); + }); + + it("refuses to install over an account Dyad is holding", async () => { + // The screen that offers this stands aside while a failure is being + // reported, so its message and log stay reachable — and the form comes + // with it. Retrying that same server is refused by preflight once Coolify + // is on it, so what is left here is a different one, whose run would + // write its own account over the only copy of this one's password. + h.settings = { + coolify: { + admin: { + email: "me@gmail.com", + password: { value: "TheEarlierOne" }, + instanceUrl: "http://198.51.100.9:8000", + }, + }, + } as Record; + + await expect(checkThenRun()).rejects.toThrow(/Sign out of Coolify first/); + expect(h.runCalls).toBe(0); + }); + + it("frees the slot even when setup failed", async () => { + // No account seeded, so nothing is held afterwards and the next run is + // admitted — the slot is the machine's, not the account's. + h.reportsAccount = false; + h.setupError = new Error("boom"); + await checkThenRun().catch(() => {}); + h.setupError = null; + await expect(checkThenRun()).resolves.toBeTruthy(); + }); +}); + +describe("a token for an unencrypted address", () => { + it("is not stored by the run that made it", async () => { + // Held instead, so closing the screen, quitting or crashing leaves Dyad + // unconnected rather than connected to something nobody agreed to. + h.setupResult = { ...(RESULT as object), secure: false, token: "1|abc" }; + await checkThenRun(); + + const saved = h.written.at(-1) as { + coolify: { accessToken?: unknown; instanceUrl?: string }; + }; + expect(saved.coolify.accessToken).toBeUndefined(); + expect(saved.coolify.instanceUrl).toBeUndefined(); + }); + + it("reaches disk only once it has been agreed to", async () => { + h.setupResult = { ...(RESULT as object), secure: false, token: "1|abc" }; + await checkThenRun(); + + await call("coolify-setup:accept-insecure-token"); + + const saved = h.written.at(-1) as { + coolify: { accessToken?: { value: string }; instanceUrl?: string }; + }; + expect(saved.coolify.accessToken?.value).toBe("1|abc"); + expect(saved.coolify.instanceUrl).toBeTruthy(); + }); + + it("is gone once the screen is put away without a word", async () => { + h.setupResult = { ...(RESULT as object), secure: false, token: "1|abc" }; + await checkThenRun(); + await call("coolify-setup:dismiss"); + + const before = h.written.length; + await call("coolify-setup:accept-insecure-token"); + + expect(h.written).toHaveLength(before); + }); + + it("is not left behind for the next case to accept", async () => { + // The third thing this module owns across a process. A case that ends an + // insecure run without accepting or dismissing would otherwise leave one + // here, and the next could store a credential the previous one made. + h.setupResult = { ...(RESULT as object), secure: false, token: "1|abc" }; + await checkThenRun(); + + resetCoolifySetupStateForTests(); + registerCoolifySetupHandlers(); + const before = h.written.length; + await call("coolify-setup:accept-insecure-token"); + + expect(h.written).toHaveLength(before); + }); + + it("stores a token for an encrypted address without asking", async () => { + // Nothing crosses the network in the clear, so there is nothing to agree + // to and nothing held. + await checkThenRun(); + + const saved = h.written.at(-1) as { + coolify: { accessToken?: { value: string } }; + }; + expect(saved.coolify.accessToken?.value).toBeTruthy(); + }); +}); + +describe("revealCredentials", () => { + const ADMIN = { + email: "me@gmail.com", + password: { value: "Abc123@xyz" }, + instanceUrl: "http://203.0.113.5:8000", + }; + + it("hands back what Dyad knows about getting in", async () => { + h.settings = { + coolify: { + instanceUrl: "http://203.0.113.5:8000", + accessToken: { value: "1|abc" }, + admin: ADMIN, + }, + }; + const result = (await call("coolify-setup:reveal-credentials")) as Record< + string, + unknown + >; + expect(result).toEqual({ + instance: { url: "http://203.0.113.5:8000", apiToken: "1|abc" }, + server: { + url: "http://203.0.113.5:8000", + email: "me@gmail.com", + password: "Abc123@xyz", + }, + }); + }); + + it("describes a server installed before any token as a server alone", async () => { + // Nothing was ever connected, so there is no instance — but the machine + // Dyad built is still named by the account it made on it. + h.settings = { coolify: { admin: ADMIN } }; + const result = (await call("coolify-setup:reveal-credentials")) as Record< + string, + unknown + >; + expect(result.instance).toBeNull(); + expect(result.server).toEqual({ + url: "http://203.0.113.5:8000", + email: "me@gmail.com", + password: "Abc123@xyz", + }); + }); + + it("keeps each address with what it opens when they are different", async () => { + // Installed a server whose token could not be minted, then connected to a + // different Coolify. One address over both would put the installed + // server's password under the other one's address. + h.settings = { + coolify: { + instanceUrl: "https://someone-elses.example.com", + accessToken: { value: "1|for-the-other-one" }, + admin: ADMIN, + }, + }; + const result = (await call("coolify-setup:reveal-credentials")) as { + instance: { url: string; apiToken: string }; + server: { url: string; password: string }; + }; + expect(result.instance.url).toBe("https://someone-elses.example.com"); + expect(result.instance.apiToken).toBe("1|for-the-other-one"); + expect(result.server.url).toBe("http://203.0.113.5:8000"); + expect(result.server.password).toBe("Abc123@xyz"); + }); + + it("hides a password it cannot read, keeping the server it belongs to", async () => { + h.settings = { + coolify: { + admin: { email: "me@gmail.com", instanceUrl: "http://h:8000" }, + }, + }; + const result = (await call("coolify-setup:reveal-credentials")) as { + server: { url: string; email: string; password: string | null }; + }; + expect(result.server.password).toBeNull(); + expect(result.server.email).toBe("me@gmail.com"); + }); + + it("has nothing to hand back once the instance is forgotten", async () => { + h.settings = { coolify: {} }; + const result = (await call("coolify-setup:reveal-credentials")) as Record< + string, + unknown + >; + expect(result).toEqual({ instance: null, server: null }); + }); + + it("answers a null server for an instance Dyad did not set up", async () => { + // Connected by pasting a token, so there is no account Dyad created. + h.settings = { + coolify: { + instanceUrl: "https://coolify.example.com", + accessToken: { value: "1|abc" }, + }, + }; + const result = (await call("coolify-setup:reveal-credentials")) as Record< + string, + unknown + >; + expect(result.server).toBeNull(); + expect(result.instance).toEqual({ + url: "https://coolify.example.com", + apiToken: "1|abc", + }); + }); +}); + +describe("cancel", () => { + it("aborts the running setup", async () => { + h.reportsAccount = false; + let release!: () => void; + h.setupResult = new Promise((resolve) => { + release = () => resolve(RESULT); + }); + await call("coolify-setup:inspect", TARGET); + const running = call("coolify-setup:run", TARGET); + // The flow is handed a signal; cancelling is what trips it. + const signal = h.lastSetupOptions?.signal as AbortSignal; + expect(signal.aborted).toBe(false); + + await call("coolify-setup:cancel"); + expect(signal.aborted).toBe(true); + release(); + await running; + }); + + it("does nothing when nothing is running", async () => { + await expect(call("coolify-setup:cancel")).resolves.toBeUndefined(); + }); +}); diff --git a/src/ipc/handlers/coolify_setup_handlers.ts b/src/ipc/handlers/coolify_setup_handlers.ts new file mode 100644 index 0000000000..c7a52544a3 --- /dev/null +++ b/src/ipc/handlers/coolify_setup_handlers.ts @@ -0,0 +1,679 @@ +import { BrowserWindow } from "electron"; +import log from "electron-log"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; +import { createTypedHandler } from "./base"; +import { + SETUP_MACHINE_REPORTED, + coolifySetupContracts, + coolifySetupEvents, +} from "../types/coolify_setup"; +import type { SetupServer, SetupSnapshot } from "../types/coolify_setup"; +import { safeSend } from "../utils/safe_sender"; +import { readSettings, writeSettings } from "@/main/settings"; +import type { Coolify } from "@/lib/schemas"; +import { + SshError, + connectSsh, + expectFingerprint, + trustOnFirstUse, +} from "../utils/ssh_client"; +import type { SshSession } from "../utils/ssh_client"; +import { ensureServerKey } from "@/coolify_setup/server_key"; +import { preflight } from "@/coolify_setup/install"; +import { runServerSetup } from "@/coolify_setup/setup_flow"; +import { CoolifySetupController } from "@/coolify_setup/controller"; +import { selectCoolifySetupCapabilities } from "@/coolify_setup/capabilities"; +import { uuidIdSource } from "@/state_machines/clock"; +import { adminEmailRefusal } from "@/shared/coolify_admin_email"; +import { isPlausibleInstanceDomain } from "@/shared/coolify_domain"; +import { sshFailureOf } from "@/shared/ssh_failure"; +import { IS_TEST_BUILD } from "../utils/test_utils"; + +const logger = log.scope("coolify_setup_handlers"); + +/** + * How long looking at a server may take. + * + * Generous, because the probe runs on somebody else's machine and a slow one + * is not a broken one — but bounded, because a wedged docker daemon never + * answers at all and the button would spin for as long as the panel was open. + */ +const INSPECT_TIMEOUT_MS = 30_000; + +/** + * The setup, and everything anyone needs to know about it. + * + * Held here rather than per window, because the machine being set up is the + * shared resource — and because an install outlives any one window. What is + * going on is asked for, not remembered on the other side. + */ +let controller: CoolifySetupController | null = null; + +/** + * What the last look at each server saw its host key to be. + * + * The panel shows this fingerprint and asks the user to commit to a + * minutes-long install on the strength of it, so the install talks to the + * machine they were shown rather than to whatever answers that address by the + * time it starts. Held here rather than sent through the renderer, which + * would make it something the caller could choose. + */ +const inspectedFingerprints = new Map(); + +/** + * The servers a check got through and liked. + * + * Separate from the pin above because the two do not come and go together: a + * server that was ready and has since had Coolify put on it loses its pass + * while the key it showed still stands. Both are written once a check has + * finished, so a pass here always belongs to the key recorded there. + */ +const readyHosts = new Set(); + +/** + * A token for an address that is not encrypted, waiting to be agreed to. + * + * Held rather than stored, because the screen that asks appears after the run + * has ended: writing it first and taking it back off if the answer was no + * meant closing the panel, quitting, or crashing left it on disk with nobody + * having agreed to anything. Kept in the process, so it is lost on a restart + * — which is the safe direction, and lands the user on the same screen a run + * whose token could not be minted already produces. + * + * One at a time, like the machine itself. + */ +let heldInsecureToken: { instanceUrl: string; token: string } | null = null; + +function broadcastState(state: SetupSnapshot) { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + safeSend(window.webContents, coolifySetupEvents.changed.channel, state); + } + } +} + +function setupController(): CoolifySetupController { + controller ??= new CoolifySetupController({ + ids: uuidIdSource, + onChanged: broadcastState, + execute: (target, hooks) => { + const key = ensureServerKey(); + // Trust on first use only when there has been no first use. A server + // that was looked at is held to what it showed then. + const pinned = inspectedFingerprints.get(serverKeyFor(target)); + /** Whether the server ever reported an account, so a run that ended + before one existed can put back what it wrote on the way in. */ + let accountConfirmed = false; + /** + * The record this run put down, or nothing if it never got that far. + * + * Connecting and the preflight both come before the password is handed + * over, and either can end the run — a cancel, a server that is not + * answering, an address that already has Coolify on it. There is + * nothing of this run's to take back off then, and the account standing + * there belongs to a server that still has it. + * + * Kept whole rather than as a flag, because the way out has to tell + * this record from anyone else's writing in the minutes since. + */ + let provisional: NonNullable | undefined; + /** + * What the account write could not store, if it could not store it. + * + * A run that then fails takes the only copy of the password with it: + * the failed screen carries a message and a log, and the call never + * returns the result that shows it. So it is tried once more where it + * starts to matter, which turns a keychain that was briefly busy into + * nothing at all. + */ + let unsavedAccount: { + credentials: { email: string; password: string }; + dashboardUrl: string; + } | null = null; + return runServerSetup({ + target: targetFrom(target, key.privateKey), + adminEmail: target.adminEmail, + verifyHostKey: pinned + ? expectFingerprint(pinned) + : trustOnFirstUse((fp) => { + inspectedFingerprints.set(serverKeyFor(target), fp); + }), + customDomain: target.customDomain, + signal: hooks.signal, + connect: (t, verify, signal): Promise => + connectSsh(t, verify, { signal }), + onProgress: ({ step, output }) => hooks.onProgress(step, output), + // Written before the installer runs, because the password it is about + // to put in the server's .env is already decided — and a run that ends + // without reaching the code below is exactly how the only copy of it + // gets lost. Put back on the way out if no account ever appeared, so a + // server that never got one does not leave a password behind for it. + onCredentialsBuilt: ({ credentials, dashboardUrl }) => { + try { + const current = readSettings().coolify; + writeSettings({ + coolify: { + ...current, + admin: { + email: credentials.email, + password: { value: credentials.password }, + instanceUrl: dashboardUrl, + }, + }, + }); + provisional = { + email: credentials.email, + password: { value: credentials.password }, + instanceUrl: dashboardUrl, + }; + } catch (error) { + // Refused rather than carried, and only here: nothing has been + // done to the server yet, so this costs a retry. Past this point + // the account exists and ending the run would throw away the only + // copy of its password, which is why the write below carries on + // instead. + // + // The reason is not passed through. This run carries a password + // and the handler is marked not to be logged, so what went wrong + // goes to the log and the user gets words of ours. + logger.error("Could not store the admin account early", error); + throw new DyadError( + "Dyad could not save the admin password on this computer, so " + + "it has not started the install — a server it cannot record " + + "the password for is one nobody can sign in to. Nothing was " + + "sent to the server. Try again once there is room on disk " + + "and the keychain is available.", + DyadErrorKind.External, + ); + } + }, + // Written the moment the account exists rather than at the end. A + // server whose dashboard never answers still has this account on it, + // and Dyad is the only thing that knows the password it invented. + onAccountKnown: ({ credentials, dashboardUrl }) => { + try { + writeSettings({ + coolify: { + ...readSettings().coolify, + admin: { + email: credentials.email, + password: { value: credentials.password }, + instanceUrl: dashboardUrl, + }, + }, + }); + unsavedAccount = null; + accountConfirmed = true; + } catch (error) { + // The account exists on the server whatever happened here, and a + // second attempt is refused because Coolify is now installed. The + // finished screen still shows the password, so ending the run + // over this would throw away the only copy of it. + unsavedAccount = { credentials, dashboardUrl }; + logger.error("Could not store the admin account", error); + } + }, + }) + .catch((error: unknown) => { + // The run is ending badly, so this is the last chance to keep a + // password nothing else holds. Guarded, because a write that fails + // again must not become the failure the user is told about. + if (unsavedAccount) { + try { + writeSettings({ + coolify: { + ...readSettings().coolify, + admin: { + email: unsavedAccount.credentials.email, + password: { + value: unsavedAccount.credentials.password, + }, + instanceUrl: unsavedAccount.dashboardUrl, + }, + }, + }); + } catch (retryError) { + logger.error("Could not store the admin account", retryError); + } + } else if (provisional && !accountConfirmed) { + // Nothing was ever seeded, so the password written on the way in + // opens nothing and comes back off. Nothing stood here before it: + // a run cannot start while Dyad holds an account, which is what + // the gate above is for. + try { + const now = readSettings().coolify; + // Minutes of installing sit between the record going down and + // this, so what stood before is only worth putting back if this + // run's record is still the one there. Anything else means + // something during the run had its own say, and the snapshot + // from before it started is not the newer answer. + // + // A password that will not decrypt is dropped on the way out of + // readSettings, with the account kept — so its absence is this + // record gone unreadable rather than somebody else's writing, + // and the rest of it still says whose it is. + const stillOurs = + now?.admin !== undefined && + now.admin.email === provisional.email && + now.admin.instanceUrl === provisional.instanceUrl && + (now.admin.password === undefined || + now.admin.password.value === provisional.password?.value); + if (stillOurs) { + // Named, not merely absent: a key that is gone reads to the + // write as one a consumer dropped, and the ciphertext on disk + // is handed back rather than cleared. + writeSettings({ coolify: { ...now, admin: undefined } }); + } + } catch (restoreError) { + logger.error( + "Could not put back the admin account", + restoreError, + ); + } + } + // A key that does not match is not the user declining, and reporting + // it as one would file it as a cancellation and say nothing. + if ( + pinned && + error instanceof SshError && + error.failure === "host-key-rejected" + ) { + throw new DyadError( + "This server is not the one Dyad looked at: its SSH identity " + + "has changed since. Nothing was sent to it. Check the address " + + "and look at the server again before installing.", + DyadErrorKind.External, + ); + } + throw error; + }) + .then((result) => { + let stored = true; + // The account exists either way, so it is stored either way. Keeping + // it only when a token was also minted discards the password in the + // one case the user needs it — where they must sign in to Coolify and + // make a token by hand, which is what the token failing means. + try { + writeSettings({ + coolify: { + ...readSettings().coolify, + admin: { + email: result.credentials.email, + password: { value: result.credentials.password }, + // Stored even when no token was minted, because then it is + // the only thing naming the server this account is on. + instanceUrl: result.dashboardUrl, + }, + // The address and token go together: an address stored without a + // token would read as an instance Dyad can talk to and cannot. + // + // Only when the address is encrypted. `coolify:save-token` + // will not store a token against a plain-HTTP address without + // the user saying so, and there is no honest reason this path + // should differ — whether HTTPS was possible is only known + // once the install has run, which says the question cannot be + // asked before, not that it can be skipped. So the token is + // held instead, and the finished screen asks. + ...(result.token && result.secure + ? { + instanceUrl: result.dashboardUrl, + accessToken: { value: result.token }, + } + : {}), + }, + }); + // Nothing has agreed to this yet, so it waits where a restart + // loses it rather than where a restart finds it. + heldInsecureToken = + result.token && !result.secure + ? { instanceUrl: result.dashboardUrl, token: result.token } + : null; + } catch (error) { + // Same reason as the write above: the server is set up, a retry is + // refused because Coolify is on it now, and the screen this + // returns to is where the password is shown. + stored = false; + logger.error("Could not store the finished setup", error); + } + return { + dashboardUrl: result.dashboardUrl, + secure: result.secure, + insecureReason: result.insecureReason ?? null, + adminEmail: result.credentials.email, + adminPassword: result.credentials.password, + tokenStored: stored && Boolean(result.token), + apiEnabled: result.apiEnabled, + tokenUnavailableReason: stored + ? (result.tokenUnavailableReason ?? null) + : // Above, not below: the credentials card sits over this + // panel, and the sentence the screen appends after this one + // already says "the details above". On this path the screen + // is the only copy of that password. + "Dyad could not save these details on this computer. Copy the " + + "password above before leaving this screen.", + version: result.version, + }; + }); + }, + }); + return controller; +} + +/** + * The key a server's fingerprint and verdict are remembered under. + * + * The port is part of it: two services on one address are two servers, and + * holding the second to the first one's key would refuse a valid one. Built + * from the address as typed rather than by parsing it as a URL, which a bare + * address is not — `fe80::1` parses as the scheme `fe80:` and leaves no + * hostname at all, so every address shaped that way would share one entry. + */ +function serverKeyFor(input: SetupServer): string { + const host = input.host + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ""); + return `${host}:${sshPort(input) ?? 22}`; +} + +/** + * Which port to knock on. + * + * The form asks for an address rather than an address and a port, because a + * server that has moved sshd is not the case this is for. Under an e2e build + * the port is named by the environment, so a test can stand a server on one + * it is allowed to bind — the same seam every other e2e-only behaviour here + * goes through. + */ +function sshPort(input: SetupServer): number | undefined { + const override = IS_TEST_BUILD ? process.env.DYAD_E2E_SSH_PORT : undefined; + return override ? Number(override) : input.port; +} + +function targetFrom(input: SetupServer, privateKey: string) { + return { + // Unbracketed, the way ssh2 and node's isIP both want an address. A + // literal typed the way documentation writes it — [2001:db8::1] — is a + // hostname to both of them: ssh2 looks it up and fails, and urlHost sees + // something that is not an IP and hands it on without the brackets a URL + // does need. The same spelling serverKeyFor already reduces to. + host: input.host.trim().replace(/^\[|\]$/g, ""), + port: sshPort(input), + username: input.username.trim(), + privateKey, + }; +} + +/** + * The same failure, with a line about the address when the address explains it. + * + * A whole URL pasted here is the likeliest wrong answer, because the token + * form asks for exactly that shape — and ssh2 takes it as a hostname, so the + * lookup fails. What the client says on its own hedges between the address + * and a port nobody is listening on, and the port is the one people go and + * look at; this settles it. Said as the rule rather than as a diagnosis: a + * slash is all that got us here, so telling someone their address "looks like + * a URL" would be a guess, and wrong for one they typed a path onto. + * + * Deliberately not a check on the way in. Nothing here decides whether an + * address is usable: an address this does not recognise connects exactly as + * it did before, and this only ever speaks after a connection has already + * failed. That is what keeps it from refusing a name that would have worked + * — a single-label name, a .local, a zone id — none of which this has to + * know about. + * + * The error object is kept rather than replaced with one of ours. The failure + * it carries never reaches the renderer — the serialized error has no such + * field — but it is read on the way out: shouldFilterTelemetryException drops + * an "unreachable" outright, as a server that does not answer is the user's + * own network rather than a fault here. A plain Error would carry no failure, + * match nothing else that filter looks for, and start reporting the connects + * this speaks for — the ones from an address with a slash in it — as + * exceptions. Every other failed connect keeps the error it was given. + * + * What the client said is still replaced, which leaves its wording unread on + * this one path. That is the trade: a message written where the address came + * from can be specific, and one written in the transport cannot. + */ +function withHostShapeHint(host: string, error: T): T { + if (!(error instanceof Error)) return error; + // A slash, and only a slash. A scheme brings two of its own and a path is + // one, so this catches both without naming either — and an address that is + // colons all the way down, which is how an IPv6 literal is written, has + // none and is left alone. + if (!host.includes("/")) return error; + // What went wrong, in the shortest form the error carries it: the errno if + // the system named one, and otherwise the failure. Both are read off the + // error rather than out of its message, and both are a word rather than a + // sentence — the sentence the client wrote offers a closed port as the + // other suspect, which this has just ruled out. + const detail = + (error as { systemCode?: string }).systemCode ?? sshFailureOf(error); + error.message = + "Enter just the server address, for example 203.0.113.5 — with no " + + `https:// and no / characters in it.${detail ? ` (${detail})` : ""}`; + return error; +} + +/** Test-only: the pin map and the controller both outlive a single case. */ +export function resetCoolifySetupStateForTests(): void { + inspectedFingerprints.clear(); + readyHosts.clear(); + // The third thing this module owns across a process. A case that finishes + // an insecure run without accepting or dismissing leaves one here, and the + // next case could then accept a credential the previous one made. + heldInsecureToken = null; + // Cancelled before disposed: disposing stops the controller talking, it does + // not stop what it started, and a run left going would go on writing + // settings while the next case is watching them. + controller?.cancel(); + controller?.dispose(); + controller = null; +} + +export function registerCoolifySetupHandlers() { + createTypedHandler(coolifySetupContracts.getServerKey, async () => { + const key = ensureServerKey(); + // Only the public half crosses to the renderer. The private half never + // leaves the main process — unlike the API token, which revealCredentials + // does hand over so the panel can show it. + return { publicKey: key.publicKey }; + }); + + createTypedHandler(coolifySetupContracts.inspect, async (_, input) => { + const key = ensureServerKey(); + let inspectTimer: ReturnType | undefined; + let fingerprint: string | null = null; + const session = await connectSsh( + targetFrom(input, key.privateKey), + // Only remembered here. What is recorded is decided once the check has + // finished, so the key and the verdict cannot disagree. + trustOnFirstUse((fp) => { + fingerprint = fp; + }), + ).catch((error: unknown) => { + // Only the connect. Once it is open the address reached something, and + // anything after this is about the server rather than what was typed. + throw withHostShapeHint(input.host, error); + }); + try { + // Bounded, because nothing else bounds it: the probe asks docker, and a + // wedged daemon never answers. Left unbounded the button span forever + // and every retry leaked another connection. + const checks = await Promise.race([ + preflight(session), + new Promise((_, reject) => { + inspectTimer = setTimeout( + () => + reject( + new DyadError( + "The server did not answer. It is reachable over SSH, so " + + "something on it is not responding — try again in a moment.", + DyadErrorKind.External, + ), + ), + INSPECT_TIMEOUT_MS, + ); + }), + ]); + // Both together, and only now. Recording the key during the handshake + // left a check that then failed with the new machine's key beside the + // old machine's pass, which is an install onto a server nobody looked + // at. A check that does not finish changes neither, so what stands is + // whatever the last finished check said. + if (fingerprint) { + inspectedFingerprints.set(serverKeyFor(input), fingerprint); + } + // Kept only while the answer stands: a server that was ready and has + // since had Coolify put on it must not keep an old pass. + if (checks.ready) readyHosts.add(serverKeyFor(input)); + else readyHosts.delete(serverKeyFor(input)); + return { + ready: checks.ready, + reason: checks.reason ?? null, + alreadyInstalled: checks.alreadyInstalled, + memoryMb: checks.memoryMb, + hostFingerprint: fingerprint, + }; + } finally { + clearTimeout(inspectTimer); + session.end(); + } + }); + + // DO NOT LOG this handler: its result carries the generated admin password. + createTypedHandler(coolifySetupContracts.run, async (_, input) => { + // Checked before anything is done, though the two cost differently. + // Coolify resolves the domain when it seeds its admin, so a domain it + // will not take is found out minutes in, on a finished install with no + // account on it. An address buildInstallScript cannot put in a shell word + // never reaches the server at all — but not before a connection, a + // preflight, and an account record written and then taken back off. + const emailRefusal = adminEmailRefusal(input.adminEmail); + if (emailRefusal) { + throw new DyadError(emailRefusal, DyadErrorKind.Validation); + } + if (input.customDomain && !isPlausibleInstanceDomain(input.customDomain)) { + throw new DyadError( + "Enter the domain on its own, with no port or path — for example " + + "coolify.yourdomain.com.", + DyadErrorKind.Validation, + ); + } + // Not while one is going: a run in flight has already written a record of + // its own, and answering a second window with "sign out first" would name + // a remedy that does not apply. The machine's own refusal is the true one, + // and it comes when the run below is started. + if ( + selectCoolifySetupCapabilities(setupController().getState()).canStart && + readSettings().coolify?.admin + ) { + // Dyad holds the only copy of one server's admin password, and a run + // writes its own over it before the installer starts. The screen that + // offers this refuses while an account is held, but not over a failure + // it is reporting — the message and the log live on that screen, so it + // stays up, and the form stays with it. Retrying the same server is + // already impossible by then, because preflight refuses a machine that + // has Coolify on it; what is left is installing a different one, which + // is this. + throw new DyadError( + "Dyad is holding the admin password for a server it set up. Sign out " + + "of Coolify first — that shows the password one last time and then " + + "forgets it — before setting up another.", + DyadErrorKind.Precondition, + ); + } + if (!readyHosts.has(serverKeyFor(input))) { + throw new DyadError( + "Check the server before installing. Dyad shows you its fingerprint " + + "first, so the install goes to the machine that answered rather " + + "than to whatever holds the address by then.", + DyadErrorKind.Precondition, + ); + } + // One at a time is the machine's rule, not a check here; it refuses by + // throwing, and the panel shows that. Outside the try on purpose: that + // refusal is the machine declining to start, so it must not be marked as + // something the machine is already showing. + const run = setupController().start(input); + try { + return await run.result; + } catch (error) { + // Awaited only to mark it: the machine recorded this before rethrowing, + // so the panel is already showing it and a toast would be the same news + // twice. Everything above never got that far and stays unmarked, which + // is what makes it speak. + if ( + typeof error === "object" && + error !== null && + Object.isExtensible(error) && + !("code" in error) + ) { + Object.assign(error, { code: SETUP_MACHINE_REPORTED }); + } + throw error; + } + }); + + createTypedHandler(coolifySetupContracts.snapshot, async () => + setupController().getState(), + ); + + createTypedHandler(coolifySetupContracts.dismiss, async () => { + // Putting the screen away without having agreed is the answer being no. + heldInsecureToken = null; + setupController().dismiss(); + }); + + // DO NOT LOG this handler: it exists to return secrets. + createTypedHandler(coolifySetupContracts.revealCredentials, async () => { + // The user's own credentials for their own server, on their own machine. + // Dyad generated the password on their behalf, so refusing to show it + // would lock them out of something they own. + const coolify = readSettings().coolify; + // Each with the address it belongs to, rather than one address over both. + // They are usually the same server and occasionally not, and handing back + // a single address would mean deciding which one it is — a decision that + // shows one server's password under another's address when it guesses + // wrong. Kept apart, there is nothing to guess. + return { + instance: coolify?.instanceUrl + ? { + url: coolify.instanceUrl, + apiToken: coolify.accessToken?.value ?? null, + } + : null, + server: coolify?.admin + ? { + url: coolify.admin.instanceUrl, + email: coolify.admin.email, + password: coolify.admin.password?.value ?? null, + } + : null, + }; + }); + + createTypedHandler(coolifySetupContracts.acceptInsecureToken, async () => { + // Nothing to write if nothing is being held: the screen only offers this + // where a run ended on an address that is not encrypted, and a run that + // ended any other way stored its token itself. + if (!heldInsecureToken) return; + writeSettings({ + coolify: { + ...readSettings().coolify, + instanceUrl: heldInsecureToken.instanceUrl, + accessToken: { value: heldInsecureToken.token }, + }, + }); + heldInsecureToken = null; + }); + + createTypedHandler(coolifySetupContracts.cancel, async () => { + // Abandoning mid-install leaves whatever the installer had done on the + // server. Nothing here tries to undo it: a half-installed Coolify is + // something the user can see and remove, whereas a Dyad that started + // deleting directories on their machine is not. + logger.info("Cancelling Coolify server setup"); + setupController().cancel(); + }); +} diff --git a/src/ipc/ipc_host.ts b/src/ipc/ipc_host.ts index 2be0fb59e2..a8068a6d1a 100644 --- a/src/ipc/ipc_host.ts +++ b/src/ipc/ipc_host.ts @@ -59,6 +59,7 @@ import { registerImageGenerationHandlers } from "./handlers/image_generation_han import { registerCoolifyHandlers } from "./handlers/coolify_handlers"; import { registerPreviewViewHandlers } from "./handlers/preview_view_handlers"; import { registerNativeThemeHandlers } from "./handlers/native_theme_handlers"; +import { registerCoolifySetupHandlers } from "./handlers/coolify_setup_handlers"; export function registerIpcHandlers() { // Register all IPC handlers by category @@ -123,4 +124,5 @@ export function registerIpcHandlers() { registerCoolifyHandlers(); registerPreviewViewHandlers(); registerNativeThemeHandlers(); + registerCoolifySetupHandlers(); } diff --git a/src/ipc/preload/channels.test.ts b/src/ipc/preload/channels.test.ts index 255b1a44a3..954753cf97 100644 --- a/src/ipc/preload/channels.test.ts +++ b/src/ipc/preload/channels.test.ts @@ -6,6 +6,10 @@ import { previewViewEvents, previewViewSendContracts, } from "../types/preview_view"; +import { + coolifySetupContracts, + coolifySetupEvents, +} from "../types/coolify_setup"; import { VALID_INVOKE_CHANNELS, VALID_RECEIVE_CHANNELS, @@ -49,3 +53,17 @@ describe("preview view preload channels", () => { } }); }); + +describe("coolify-setup preload channels", () => { + it("allows every Coolify setup invoke and receive contract", () => { + // The preload's on() takes `ValidReceiveChannel | string`, so a channel + // missing from the list compiles and fails only when the install runs — + // which is minutes in, on a real server. + for (const contract of Object.values(coolifySetupContracts)) { + expect(VALID_INVOKE_CHANNELS).toContain(contract.channel); + } + for (const event of Object.values(coolifySetupEvents)) { + expect(VALID_RECEIVE_CHANNELS).toContain(event.channel); + } + }); +}); diff --git a/src/ipc/preload/channels.ts b/src/ipc/preload/channels.ts index ec5b371aaf..a059d4f115 100644 --- a/src/ipc/preload/channels.ts +++ b/src/ipc/preload/channels.ts @@ -27,6 +27,10 @@ import { import { mcpContracts } from "../types/mcp"; import { vercelContracts } from "../types/vercel"; import { coolifyContracts, coolifyEvents } from "../types/coolify"; +import { + coolifySetupContracts, + coolifySetupEvents, +} from "../types/coolify_setup"; import { supabaseContracts, supabaseEvents } from "../types/supabase"; import { neonContracts } from "../types/neon"; import { migrationContracts } from "../types/migration"; @@ -115,6 +119,7 @@ export const VALID_INVOKE_CHANNELS = [ ...getInvokeChannels(mcpContracts), ...getInvokeChannels(vercelContracts), ...getInvokeChannels(coolifyContracts), + ...getInvokeChannels(coolifySetupContracts), ...getInvokeChannels(supabaseContracts), ...getInvokeChannels(neonContracts), ...getInvokeChannels(migrationContracts), @@ -184,6 +189,7 @@ export const VALID_RECEIVE_CHANNELS = [ ...getReceiveChannels(agentEvents), ...getReceiveChannels(gitEvents), ...getReceiveChannels(coolifyEvents), + ...getReceiveChannels(coolifySetupEvents), ...getReceiveChannels(connectionFlowEvents), ...getReceiveChannels(supabaseEvents), ...getReceiveChannels(systemEvents), diff --git a/src/ipc/types/coolify.ts b/src/ipc/types/coolify.ts index 8414cfa4f2..df2a56cd01 100644 --- a/src/ipc/types/coolify.ts +++ b/src/ipc/types/coolify.ts @@ -89,8 +89,15 @@ export const CoolifyStatusSchema = z.object({ * tell that the token changed without ever being told what it is. */ tokenId: z.string().nullable(), - /** Remembered across a token clear, so re-entering a token does not retype it. */ + /** The Coolify Dyad is connected to, or null once it has been forgotten. */ instanceUrl: z.string().nullable(), + /** + * The address of a server Dyad set up and holds an admin account for, or + * null. Not a secret, and the panel needs it without the password: while + * this is set the only Coolify Dyad will connect to is this one, so it + * pins the address field and stands in the way of setting up another. + */ + serverUrl: z.string().nullable(), connection: CoolifyConnectionSchema.nullable(), appUrl: z.string().nullable(), /** Epoch millis of the last successful deploy, or null if never deployed. */ diff --git a/src/ipc/types/coolify_setup.ts b/src/ipc/types/coolify_setup.ts new file mode 100644 index 0000000000..49c53db03c --- /dev/null +++ b/src/ipc/types/coolify_setup.ts @@ -0,0 +1,343 @@ +import { z } from "zod"; +import type { CoolifySetupState } from "@/coolify_setup/state"; +import { + defineContract, + defineEvent, + createClient, + createEventClient, +} from "../contracts/core"; + +// ============================================================================= +// Coolify Setup Schemas +// ============================================================================= + +export const SetupStepSchema = z.enum([ + "connecting", + "checking-server", + "installing", + "waiting-for-dashboard", + "verifying-account", + "securing", + "creating-token", + "done", +]); + +export const ServerKeySchema = z.object({ + /** The line the user adds to their server's authorized_keys. */ + publicKey: z.string(), +}); + +/** + * Marks a failure the machine took on and has already put on screen. + * + * The panel shows anything it cannot attribute, because an error nobody + * reports is a button that does nothing. So the mark goes on the case that + * IS accounted for — a run that failed or was cancelled, which the finished + * screen carries with the installer's own words — and everything else, + * including whatever the IPC layer refuses before this handler is reached, + * is said out loud. + */ +export const SETUP_MACHINE_REPORTED = "coolify-setup-machine-reported"; + +/** + * Where the server is. Everything needed to reach it, and nothing else. + * + * Separate from the address of the account to create, because looking at a + * server does not need one — demanding it there rejected a check the panel + * was offering before the email had been typed. + */ +export const SetupServerSchema = z.object({ + host: z.string().min(1), + /** Coolify's installer needs root, and says so in its own documentation. */ + username: z.string().min(1).default("root"), + port: z.number().int().positive().max(65535).optional(), +}); + +export const SetupTargetSchema = SetupServerSchema.extend({ + adminEmail: z.string().min(3), + /** + * A domain the user owns, pointed at this server. + * + * Optional because Dyad can derive one from the address. Supplying one is + * better where they have it: it is theirs, and it does not draw on the free + * shared service's certificate allowance. + */ + customDomain: z.string().optional(), +}); + +/** + * What Dyad found when it looked at the server, before touching it. + * + * Reported rather than acted on, so the panel can explain the two problems a + * user can fix immediately instead of failing several minutes into an install. + */ +export const SetupPreflightSchema = z.object({ + ready: z.boolean(), + reason: z.string().nullable(), + alreadyInstalled: z.boolean(), + memoryMb: z.number().nullable(), + /** Shown so the user can compare it against their provider's console. */ + hostFingerprint: z.string().nullable(), +}); + +export const SetupResultSchema = z.object({ + dashboardUrl: z.string(), + /** + * Whether that address is encrypted. + * + * Dyad asks for a certificate and settles for plain HTTP when none arrives, + * so this is the outcome rather than a setting — and the token it carries has + * root abilities and travels on every deploy. + */ + secure: z.boolean(), + insecureReason: z.string().nullable(), + adminEmail: z.string(), + /** + * Returned so the fallback screen can show it when no token was created. + * + * On the ordinary path it is stored instead, and read back through + * revealCredentials — a password shown once is a password nobody can use. + */ + adminPassword: z.string(), + /** + * Whether Dyad ended up holding a token, which is not the same as one + * having been created: the address may be unencrypted with the offer to + * keep it not taken, or storing it may have failed on this computer. + * tokenUnavailableReason is what says Coolify's API was the thing that + * could not be opened. + */ + tokenStored: z.boolean(), + /** + * Whether Coolify's API was switched on before anything went wrong. + * + * Separate from tokenStored because Dyad enables the API first and mints + * afterwards: a mint that fails leaves the API on, so the guidance to go + * and enable it is wrong even though no token came back. + */ + apiEnabled: z.boolean(), + tokenUnavailableReason: z.string().nullable(), + version: z.string().nullable(), +}); + +/** + * What Dyad can tell the user about getting into their own server. + * + * Two records, each carrying its own address, because they are facts about + * two different things: the instance Dyad talks to, and a machine Dyad built. + * Usually the same server, but not always — an install whose token could not + * be minted leaves an account behind while the user connects somewhere else. + * Folded into one address they would have to be checked against each other + * before either could be shown, and a check that guessed wrong would put one + * server's password under another's address. + * + * Null where Dyad never had it: an instance connected by pasting a token has + * no admin account Dyad created, and a server set up but not connected to has + * no instance. + */ +export const RevealedCredentialsSchema = z.object({ + instance: z + .object({ + url: z.string(), + apiToken: z.string().nullable(), + }) + .nullable(), + server: z + .object({ + url: z.string(), + email: z.string(), + password: z.string().nullable(), + }) + .nullable(), +}); + +/** + * What the main process is doing with a server, as the panel sees it. + * + * The panel keeps none of this: an install outlives the screen that started + * it, so the screen asks rather than remembers. Mirrors CoolifySetupState, + * with an assertion beside that type so neither can drift from the other. + */ +export const SetupInvocationRefSchema = z.object({ + kind: z.literal("coolify-setup"), + entityKey: z.string(), + operationId: z.string(), +}); + +export const SetupSnapshotSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("idle") }), + z.object({ + type: z.literal("running"), + host: z.string(), + invocationRef: SetupInvocationRefSchema, + step: SetupStepSchema, + log: z.string(), + stopping: z.boolean(), + }), + z.object({ + type: z.literal("done"), + host: z.string(), + invocationRef: SetupInvocationRefSchema, + result: SetupResultSchema, + }), + z.object({ + type: z.literal("failed"), + host: z.string(), + invocationRef: SetupInvocationRefSchema, + message: z.string(), + log: z.string(), + cancelled: z.boolean(), + /** Left behind on the server, and still the user's to undo. */ + warning: z.string().optional(), + }), +]); + +// ============================================================================= +// Coolify Setup Contracts +// ============================================================================= + +export const coolifySetupContracts = { + /** + * The public key the user has to install before anything else can happen. + * + * First, because it is the one manual step and nothing works until it is + * done. Generated on demand and reused afterwards. + */ + getServerKey: defineContract({ + channel: "coolify-setup:get-server-key", + input: z.void(), + output: ServerKeySchema, + }), + + /** + * Connects and looks, without changing anything. + * + * Separate from running the setup so the panel can show what it found — the + * host fingerprint especially — and let the user decide before a minutes-long + * install starts. + */ + inspect: defineContract({ + channel: "coolify-setup:inspect", + input: SetupServerSchema, + output: SetupPreflightSchema, + }), + + // DO NOT LOG this handler: its result carries an admin password. + run: defineContract({ + channel: "coolify-setup:run", + input: SetupTargetSchema, + output: SetupResultSchema, + // A token stored here makes every app readable as connected, exactly as + // saving one by hand does. + invalidates: () => [{ family: "apps" }, { family: "coolify" }], + // Claimed, because this panel refreshes coolify itself and chooses when: + // an install that ended on plain HTTP has a warning to show first, and a + // refresh here flips the panel to connected and unmounts the screen + // carrying it. `apps` is not claimed — nothing repeats that locally. + originHandles: () => [{ family: "coolify" }], + }), + + /** + * The credentials for a server Dyad set up, on request. + * + * A separate call rather than part of the status, so the panel can show + * them long after the install that made them. The finished screen carries + * its own copy in the snapshot while it is up; this is how they are read + * once it is gone. + */ + revealCredentials: defineContract({ + channel: "coolify-setup:reveal-credentials", + input: z.void(), + output: RevealedCredentialsSchema, + }), + + /** + * What is going on right now, asked on mount rather than remembered. + * + * DO NOT LOG this handler. A finished run carries the admin password Dyad + * invented, the same secret `run` and `revealCredentials` are marked for — + * and this hands back the identical payload, as does the `changed` event + * that pushes it to every window. + */ + snapshot: defineContract({ + channel: "coolify-setup:snapshot", + input: z.void(), + output: SetupSnapshotSchema, + }), + + /** + * The user read the unencrypted-address warning and accepted it. + * + * A token for an address that is not encrypted is held rather than stored + * when the run ends, so that closing the screen, quitting, or a crash + * leaves Dyad unconnected rather than connected to something nobody agreed + * to. This is the only way it reaches disk. Nothing to decline: not + * accepting is simply never calling it. + */ + acceptInsecureToken: defineContract({ + channel: "coolify-setup:accept-insecure-token", + input: z.void(), + output: z.void(), + // The same write `coolify:save-token` makes, so the same reach: a window + // that watched this install finish is otherwise still offering to set a + // server up, and pressing it there is refused for holding an account. + invalidates: () => [{ family: "apps" }, { family: "coolify" }], + // The finished screen refreshes on its own way out, in the order it needs + // — the panel behind it must not be handed back before the token lands. + originHandles: () => [{ family: "coolify" }], + }), + + /** The user has read the finished screen; put the panel back to the form. */ + dismiss: defineContract({ + channel: "coolify-setup:dismiss", + input: z.void(), + output: z.void(), + }), + + cancel: defineContract({ + channel: "coolify-setup:cancel", + input: z.void(), + output: z.void(), + }), +} as const; + +export const coolifySetupEvents = { + /** + * The whole of what is going on, rather than a step at a time. + * + * Sending the state instead of a delta is what lets a window that was not + * there for the earlier events still show the install correctly. + */ + // DO NOT LOG. Carries the same finished-run payload as `snapshot`, admin + // password and all, to every window. + changed: defineEvent({ + channel: "coolify-setup:changed", + payload: SetupSnapshotSchema, + }), +} as const; + +/** + * The wire shape and the machine's state are the same thing. + * + * Asserted rather than assumed. The machine owns its types — a pure machine + * module may not import from here — so this is where the two are held + * together, and either drifting fails type-checking rather than failing in a + * window that cannot parse what it was sent. Tupled, because a bare `extends` + * distributes over the union and answers true when any one member fits. + */ +type AssignableTo = [Source] extends [Target] ? true : never; +const _snapshotMatchesState: [ + AssignableTo, + AssignableTo, +] = [true, true]; +void _snapshotMatchesState; + +export const coolifySetupClient = createClient(coolifySetupContracts); +export const coolifySetupEventClient = createEventClient(coolifySetupEvents); + +export type SetupTarget = z.infer; +export type SetupServer = z.infer; +export type SetupPreflight = z.infer; +export type SetupResult = z.infer; +export type SetupSnapshot = z.infer; +export type SetupStep = z.infer; +export type RevealedCredentials = z.infer; diff --git a/src/ipc/types/index.ts b/src/ipc/types/index.ts index 0c4e2d3d65..1735438530 100644 --- a/src/ipc/types/index.ts +++ b/src/ipc/types/index.ts @@ -38,6 +38,19 @@ export { export { mcpContracts } from "./mcp"; export { vercelContracts } from "./vercel"; export { coolifyContracts, coolifyEvents, coolifyEventClient } from "./coolify"; +export { + coolifySetupContracts, + coolifySetupEvents, + coolifySetupEventClient, +} from "./coolify_setup"; +export type { + SetupTarget, + SetupPreflight, + SetupResult, + SetupSnapshot, + SetupStep, + RevealedCredentials, +} from "./coolify_setup"; export type { CoolifyConnection, CoolifyDeploySnapshot, @@ -517,6 +530,7 @@ import { import { mcpClient } from "./mcp"; import { vercelClient } from "./vercel"; import { coolifyClient, coolifyEventClient } from "./coolify"; +import { coolifySetupClient, coolifySetupEventClient } from "./coolify_setup"; import { supabaseClient, supabaseEventClient } from "./supabase"; import { neonClient } from "./neon"; import { migrationClient } from "./migration"; @@ -600,6 +614,7 @@ export const ipc = { mcp: mcpClient, vercel: vercelClient, coolify: coolifyClient, + coolifySetup: coolifySetupClient, supabase: supabaseClient, neon: neonClient, migration: migrationClient, @@ -651,6 +666,7 @@ export const ipc = { tests: testsEventClient, userInput: userInputEventClient, coolify: coolifyEventClient, + coolifySetup: coolifySetupEventClient, imageGeneration: imageGenerationEventClient, windowInfrastructure: windowInfrastructureEventClient, distributedMachine: distributedMachineEventClient, diff --git a/src/ipc/utils/dns_resolve.ts b/src/ipc/utils/dns_resolve.ts new file mode 100644 index 0000000000..f0f30708dc --- /dev/null +++ b/src/ipc/utils/dns_resolve.ts @@ -0,0 +1,60 @@ +import * as dns from "node:dns/promises"; + +/** + * Asking DNS where a name points, without mistaking silence for an answer. + * + * Shared because two callers need the same distinction: the app-domain check + * the user runs before saving, and the instance domain Dyad points Coolify at + * during setup. Both are advisory, and both are wrong in the same expensive + * way if a resolver that could not be reached reads as "no such record". + */ + +/** A resolver saying "no such record" — anything else is our problem, not DNS's. */ +const NO_RECORD_CODES = new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]); + +/** + * Bounded, because Save waits on it. + * + * The check is advisory, so a resolver that never answers must not be able to + * hold the button indefinitely. The bound is per attempt per configured + * nameserver, so the wait scales with how many the machine has: six seconds + * against a single stub resolver, and proportionally more where several are + * listed. Two tries rather than the default four keeps that multiple small. A + * timeout still arrives as an error code the caller reads as "could not ask" + * rather than as a missing record. + */ +const resolver = new dns.Resolver({ timeout: 3_000, tries: 2 }); + +/** + * Both families, distinguishing "no record" from "could not ask". + * + * A timeout or an unreachable resolver must not be reported as a missing + * record: telling someone to fix DNS that is already correct is exactly the + * confident-but-wrong advice the unknown verdict exists to avoid. + */ +export async function resolveBoth( + hostname: string, +): Promise<{ addresses: string[]; failed: boolean }> { + const attempt = async (fn: (h: string) => Promise) => { + try { + return { addresses: await fn(hostname), failed: false }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? ""; + return { addresses: [] as string[], failed: !NO_RECORD_CODES.has(code) }; + } + }; + const [v4, v6] = await Promise.all([ + attempt((h) => resolver.resolve4(h)), + attempt((h) => resolver.resolve6(h)), + ]); + return { + addresses: [...v4.addresses, ...v6.addresses], + // One family answering is enough. But a definitive "no such record" from + // one and a failed lookup from the other is not the same as no records: + // the family we could not reach may hold the one that works. + failed: + v4.addresses.length === 0 && + v6.addresses.length === 0 && + (v4.failed || v6.failed), + }; +} diff --git a/src/ipc/utils/ssh_client.test.ts b/src/ipc/utils/ssh_client.test.ts new file mode 100644 index 0000000000..1726973f42 --- /dev/null +++ b/src/ipc/utils/ssh_client.test.ts @@ -0,0 +1,625 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { EventEmitter } from "events"; + +const h = vi.hoisted(() => { + /** A real ed25519 host key, so the fingerprint below is a golden value. */ + const HOST_KEY_B64 = + "AAAAC3NzaC1lZDI1NTE5AAAAIJiGrEZTbNIEO9U84zpD4H6mNsXVcl4il3RsnZ4MQImg"; + return { + clients: [] as FakeClientShape[], + HOST_KEY_B64, + // Scripted here rather than on the target, because connectSsh builds the + // library's config from named fields and would drop anything extra. + nextFailure: null as unknown, + }; +}); + +interface FakeStreamShape extends EventEmitter { + stderr: EventEmitter; + written: string | null; + closed: boolean; + write(data: string, cb?: () => void): void; + eof(): void; + end(data?: string): void; + close(): void; +} + +interface FakeClientShape extends EventEmitter { + connectConfig: Record | null; + execHandler: + | ((command: string, cb: (err: unknown, stream: unknown) => void) => void) + | null; + ended: boolean; +} + +/** + * Stands in for ssh2's Client. + * + * Scripted rather than a real server: what this file checks is how the wrapper + * reads the library's own signals, and those are values the library hands over + * rather than behaviour a server produces. The values themselves were captured + * from a live box — see the failure table below. + */ +vi.mock("ssh2", () => { + const { EventEmitter: EE } = require("events"); + class FakeClient extends EE { + connectConfig: Record | null = null; + execHandler: + | ((command: string, cb: (err: unknown, stream: unknown) => void) => void) + | null = null; + ended = false; + + connect(config: Record) { + this.connectConfig = config; + h.clients.push(this as unknown as FakeClientShape); + const verifier = config.hostVerifier as + | ((key: Buffer) => boolean) + | undefined; + queueMicrotask(() => { + // The verifier runs during the handshake, before anything is sent. + if (verifier && !verifier(Buffer.from(h.HOST_KEY_B64, "base64"))) { + this.emit( + "error", + Object.assign(new Error("Host denied"), { level: "handshake" }), + ); + return; + } + if (h.nextFailure) { + this.emit("error", h.nextFailure); + return; + } + this.emit("ready"); + }); + } + + exec(command: string, cb: (err: unknown, stream: unknown) => void) { + this.execHandler?.(command, cb); + } + + end() { + this.ended = true; + } + } + return { Client: FakeClient }; +}); + +const HOST_KEY_FINGERPRINT = + "SHA256:3FQS9D0B0DVizoYtw1hNV09EClubwWqRUXoFnRTu6nA"; + +import { + connectSsh, + hostKeyFingerprint, + trustOnFirstUse, + expectFingerprint, +} from "./ssh_client"; +import type { SshError } from "./ssh_client"; + +const TARGET = { host: "203.0.113.5", username: "root", privateKey: "KEY" }; + +beforeEach(() => { + h.clients.length = 0; + h.nextFailure = null; +}); + +describe("hostKeyFingerprint", () => { + it("prints what ssh-keygen prints for the same key", () => { + // Golden pair captured from a live server, so this compares against + // OpenSSH rather than against a second run of our own arithmetic. The user + // reads this string next to their provider's console; two spellings of one + // key would read as two keys. + expect(hostKeyFingerprint(Buffer.from(h.HOST_KEY_B64, "base64"))).toBe( + HOST_KEY_FINGERPRINT, + ); + }); +}); + +describe("connecting", () => { + it("reports the fingerprint before trusting the server", async () => { + let seen: string | null = null; + const session = await connectSsh( + TARGET, + trustOnFirstUse((fp) => (seen = fp)), + ); + expect(seen).toBe(HOST_KEY_FINGERPRINT); + session.end(); + }); + + it("connects when the fingerprint is the one a provider promised", async () => { + const session = await connectSsh( + TARGET, + expectFingerprint(HOST_KEY_FINGERPRINT), + ); + expect(h.clients[0].connectConfig?.host).toBe("203.0.113.5"); + session.end(); + }); + + it("refuses a server whose key is not the promised one", async () => { + await expect( + connectSsh(TARGET, expectFingerprint("SHA256:something-else")), + ).rejects.toMatchObject({ failure: "host-key-rejected" }); + }); + + it("calls a declined key the user's decision, not a warning", async () => { + // Both arrive as a handshake failure, but only one of them means the + // server's identity changed. Reporting a decline as a mismatch would tell + // someone their machine was tampered with because they clicked no. + const error = (await connectSsh(TARGET, () => false).catch( + (e) => e, + )) as SshError; + expect(error.failure).toBe("host-key-rejected"); + expect((error as unknown as { kind: string }).kind).toBe("user_cancelled"); + }); +}); + +/** + * The five shapes a connection fails in, with the level and code the library + * actually produced for each against a real server. + */ +describe("classifying a failed connection", () => { + const CASES: Array<{ + name: string; + raw: Record; + failure: string; + kind: string; + /** What the system called it, where it called it anything. */ + systemCode?: string; + }> = [ + { + name: "a key the server will not take", + raw: { + level: "client-authentication", + message: "All configured authentication methods failed", + }, + failure: "auth-rejected", + kind: "auth", + }, + { + name: "a host that never answers", + raw: { + level: "client-timeout", + message: "Timed out while waiting for handshake", + }, + failure: "timeout", + kind: "external", + }, + { + name: "a name that does not resolve", + raw: { + level: "client-socket", + code: "ENOTFOUND", + message: "getaddrinfo ENOTFOUND nope.invalid", + }, + failure: "unreachable", + kind: "external", + systemCode: "ENOTFOUND", + }, + { + name: "a closed port", + raw: { + level: "client-socket", + code: "ECONNREFUSED", + message: "connect ECONNREFUSED", + }, + failure: "unreachable", + kind: "external", + systemCode: "ECONNREFUSED", + }, + { + name: "two ends that cannot agree on ciphers", + raw: { + level: "handshake", + message: "Handshake failed: no matching key exchange algorithm", + }, + failure: "handshake-failed", + kind: "external", + }, + { + // The bucket, but a named one: a socket error this does not recognise + // still says what the system called it, and that name is what makes it + // worth reporting. + name: "an error nothing here recognises, named", + raw: { level: "client-socket", code: "EPIPE", message: "broken pipe" }, + failure: "unknown", + kind: "external", + systemCode: "EPIPE", + }, + { + name: "anything else", + raw: { level: "client-socket", message: "kernel exploded" }, + failure: "unknown", + kind: "external", + }, + ]; + + it("does not call a failed negotiation a changed identity", async () => { + // The key being turned down is answered before this is reached, where + // the verifier said no. What is left is an old or hardened sshd with no + // algorithm in common — and telling that user their machine may have + // been swapped is a false alarm about the one thing this checks for. + h.nextFailure = Object.assign( + new Error("Handshake failed: no matching host key format"), + { level: "handshake" }, + ); + const error = (await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ).catch((e) => e)) as SshError; + + expect(error.failure).not.toBe("host-key-rejected"); + expect(error.message).not.toMatch(/different host key/); + expect(error.message).toMatch(/could not agree on how to connect/); + // What the server said, so the real cause is not lost. + expect(error.message).toMatch(/no matching host key format/); + // Said once: the sentence above already reports a handshake that failed. + expect(error.message).not.toMatch(/connect: Handshake failed/i); + }); + + it.each(CASES)( + "reads $name as $failure", + async ({ raw, failure, kind, systemCode }) => { + h.nextFailure = Object.assign(new Error(String(raw.message)), raw); + const error = (await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ).catch((e) => e)) as SshError; + expect(error.failure).toBe(failure); + expect((error as unknown as { kind: string }).kind).toBe(kind); + // Carried across rather than left in the sentence it was written into, + // so a caller that wants to name the fault does not have to read one. + expect(error.systemCode).toBe(systemCode); + }, + ); +}); + +describe("running a command", () => { + function scriptStream( + client: FakeClientShape, + script: (stream: FakeStream) => void, + ) { + client.execHandler = (_command, cb) => { + const stream = new FakeStream(); + cb(null, stream); + script(stream); + }; + } + + class FakeStream extends EventEmitter implements FakeStreamShape { + stderr = new EventEmitter(); + written: string | null = null; + eofSent = false; + ended = false; + closed = false; + write(data: string, cb?: () => void) { + this.written = (this.written ?? "") + data; + cb?.(); + } + eof() { + this.eofSent = true; + } + end(data?: string) { + this.written = data ?? null; + this.ended = true; + } + close() { + this.closed = true; + } + } + + it("sends EOF on stdin without finishing the write side", async () => { + // Both halves matter. Without the EOF, a command that reads stdin waits + // for a line that is never coming. With `end()` instead, Node destroys + // the channel as soon as the server sends its own EOF, which loses the + // exit status and, against a real sshd, kills a command still running. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + let stream!: FakeStream; + scriptStream(h.clients[0], (s) => { + stream = s; + queueMicrotask(() => s.emit("close", 0)); + }); + + await session.run("uname -a"); + + expect(stream.eofSent).toBe(true); + expect(stream.ended).toBe(false); + expect(stream.written).toBeNull(); + }); + + it("hands input to stdin rather than the command line", async () => { + // Everything this runs remotely is a script, and a script on a command + // line has to survive a shell. Feeding stdin removes that layer entirely. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + let stream!: FakeStreamShape; + scriptStream(h.clients[0], (s) => { + stream = s; + queueMicrotask(() => s.emit("close", 0)); + }); + + await session.run("cat", { input: "$(rm -rf /) 'quoted'\n" }); + + expect(stream.written).toBe("$(rm -rf /) 'quoted'\n"); + }); + + it("reports output as it arrives, not only at the end", async () => { + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + scriptStream(h.clients[0], (s) => { + s.emit("data", Buffer.from("first\n")); + s.stderr.emit("data", Buffer.from("warning\n")); + s.emit("data", Buffer.from("second\n")); + queueMicrotask(() => s.emit("close", 0)); + }); + + const chunks: string[] = []; + const result = await session.run("build", { + onOutput: (c) => chunks.push(c), + }); + + expect(chunks).toEqual(["first\n", "warning\n", "second\n"]); + expect(result.stdout).toBe("first\nsecond\n"); + expect(result.stderr).toBe("warning\n"); + expect(result.code).toBe(0); + }); + + it("stops when cancelled mid-run", async () => { + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + let stream!: FakeStreamShape; + scriptStream(h.clients[0], (s) => { + stream = s; + }); + const controller = new AbortController(); + + const running = session.run("long-install", { signal: controller.signal }); + controller.abort(); + + await expect(running).rejects.toMatchObject({ kind: "user_cancelled" }); + expect(stream.closed).toBe(true); + }); + + it("stops when the server never opens the channel", async () => { + // A frozen box still answers TCP but stops answering SSH, so the callback + // that hands over the stream never runs. A listener attached inside it + // would never exist, and Cancel would do nothing for as long as the + // command was outstanding — which wedged the whole setup. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + // Asked for, never answered. + h.clients[0].execHandler = () => {}; + const controller = new AbortController(); + + const running = session.run("long-install", { signal: controller.signal }); + controller.abort(); + + await expect(running).rejects.toMatchObject({ kind: "user_cancelled" }); + }); + + it("lets go of a channel that arrives after the cancel", async () => { + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + let hand!: (stream: FakeStream) => void; + h.clients[0].execHandler = (_command, cb) => { + hand = (stream) => cb(null, stream); + }; + const controller = new AbortController(); + const running = session.run("long-install", { signal: controller.signal }); + + controller.abort(); + const arrived = new FakeStream(); + hand(arrived); + + await expect(running).rejects.toMatchObject({ kind: "user_cancelled" }); + expect(arrived.closed).toBe(true); + }); + + it("asks the connection to notice a peer that stops answering", async () => { + // Nothing else bounds a command: readyTimeout covers the handshake only. + await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + expect(h.clients.at(-1)?.connectConfig?.keepaliveInterval).toBeTruthy(); + }); + + it("reports the connection dying rather than an exit code", async () => { + // The keepalive tears down a peer that stopped answering, which closes + // any open channel with no exit code. Read as an ordinary finish, that + // becomes "the installer failed (exit undefined)" — the connection's + // death reported as the command's verdict. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + let stream!: FakeStream; + scriptStream(h.clients[0], (s) => { + stream = s; + }); + + const running = session.run("preflight"); + h.clients[0].emit("error", { + level: "client-timeout", + message: "Keepalive timeout", + }); + // What ssh2 actually emits when no exit-status arrived: its Channel + // initialises _exit.code to undefined, and utils.js emits that value. + stream.emit("close", undefined); + + await expect(running).rejects.toMatchObject({ kind: "external" }); + }); + + it("keeps a result the command did report, even if the link then died", async () => { + // The command finished and said how. A connection that dies immediately + // afterwards must not turn that into a failure of the install. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + let stream!: FakeStream; + scriptStream(h.clients[0], (s) => { + stream = s; + }); + + const running = session.run("preflight"); + h.clients[0].emit("error", { + level: "client-timeout", + message: "Keepalive timeout", + }); + stream.emit("close", 0); + + await expect(running).resolves.toMatchObject({ code: 0 }); + }); + + it("says the server stopped rather than telling them to check the address", async () => { + // Before the handshake, "check the address and that port 22 is open" is + // the right advice. After it, the address plainly worked — repeating it + // sends the user to look at the thing that is not wrong. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + let stream!: FakeStream; + scriptStream(h.clients[0], (s) => { + stream = s; + }); + + const running = session.run("preflight"); + h.clients[0].emit("error", { + level: "client-timeout", + message: "Keepalive timeout", + }); + stream.emit("close", undefined); + + await expect(running).rejects.toMatchObject({ + message: expect.stringContaining("stopped answering"), + }); + }); + + it("blames the connection, not the channel, when the link died", async () => { + // The channel error is only how a dead connection surfaced here. Reported + // as itself it says "Not connected", which describes the symptom. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + h.clients[0].emit("error", { + level: "client-timeout", + message: "Keepalive timeout", + }); + h.clients[0].execHandler = (_command, cb) => { + cb(new Error("Not connected"), undefined); + }; + + await expect(session.run("preflight")).rejects.toMatchObject({ + failure: "timeout", + }); + }); + + it("still reports an ordinary non-zero exit as itself", async () => { + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + scriptStream(h.clients[0], (s) => { + s.emit("close", 1); + }); + + await expect(session.run("preflight")).resolves.toMatchObject({ code: 1 }); + }); + + it("reports a channel error instead of letting it take the process down", async () => { + // A stream with no error listener throws where it stands, which in the + // main process means the whole app rather than the command. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + scriptStream(h.clients[0], (s) => { + queueMicrotask(() => s.emit("error", new Error("channel died"))); + }); + + await expect(session.run("uname -a")).rejects.toThrow(/channel died/); + }); + + it("lets go of the signal when the socket is already gone", async () => { + // ssh2 throws where it stands rather than calling back, which would skip + // the cleanup and strand a listener on a signal nobody will ever answer. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + h.clients[0].execHandler = () => { + throw new Error("Not connected"); + }; + const controller = new AbortController(); + const letGo = vi.spyOn(controller.signal, "removeEventListener"); + + await expect( + session.run("preflight", { signal: controller.signal }), + ).rejects.toBeTruthy(); + + // Watched directly: an AbortSignal does not say how many listeners it + // holds, and "nothing bad happened afterwards" is true whether or not one + // was left behind. + expect(letGo).toHaveBeenCalled(); + }); + + it("gives up on a command that never answers, and closes the channel", async () => { + // Giving up on the answer is not the same as stopping the work: a command + // left running holds its channel open until the whole session ends. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + let stream!: FakeStream; + scriptStream(h.clients[0], (s) => { + stream = s; + }); + + // Named apart from a lost connection: the link is still good, so a + // caller that polls can ask again rather than giving up on the server. + await expect( + session.run("docker ps", { timeoutMs: 10 }), + ).rejects.toMatchObject({ failure: "command-timeout" }); + expect(stream.closed).toBe(true); + }); + + it("leaves a command alone when no bound was asked for", async () => { + // An installer legitimately runs for minutes with nothing to say, so + // unbounded stays the default. + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + scriptStream(h.clients[0], (s) => { + setTimeout(() => s.emit("close", 0), 30); + }); + + await expect(session.run("install.sh")).resolves.toMatchObject({ code: 0 }); + }); + + it("refuses before starting when already cancelled", async () => { + const session = await connectSsh( + TARGET, + trustOnFirstUse(() => {}), + ); + const controller = new AbortController(); + controller.abort(); + + await expect( + session.run("long-install", { signal: controller.signal }), + ).rejects.toMatchObject({ kind: "user_cancelled" }); + }); +}); diff --git a/src/ipc/utils/ssh_client.ts b/src/ipc/utils/ssh_client.ts new file mode 100644 index 0000000000..cf6834dcac --- /dev/null +++ b/src/ipc/utils/ssh_client.ts @@ -0,0 +1,471 @@ +import type { ClientChannel, ConnectConfig } from "ssh2"; +import type { SshFailure } from "@/shared/ssh_failure"; +import { createHash } from "crypto"; +import log from "electron-log"; +import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; + +const logger = log.scope("ssh_client"); + +/** + * Dyad's SSH client, for setting a server up before Coolify exists on it. + * + * Deploying needs no SSH at all — Coolify clones from GitHub with a key Dyad + * hands it. This is the other half: reaching a bare machine to install Coolify + * in the first place, which nothing else in Dyad does. + */ + +/** Long enough for a slow link, short enough that a wrong address gives up. */ +const CONNECT_TIMEOUT_MS = 20_000; + +export interface SshTarget { + host: string; + port?: number; + username: string; + /** OpenSSH format. Node's PEM export is rejected by the wire library. */ + privateKey: string; +} + +/** + * What went wrong, from the connection itself rather than from its message. + * + * Kept as a closed set because the caller decides what to tell the user from + * it: a rejected key asks them to check the key is on the server, and an + * unreachable host asks them to check the address. Reading English out of a + * message to make that choice breaks the first time the wording moves. + */ +export type { SshFailure }; + +export class SshError extends DyadError { + constructor( + readonly failure: SshFailure, + message: string, + kind: DyadErrorKind, + /** + * What the operating system called it, where it said anything. + * + * Carried rather than left in the message for the same reason `failure` + * is: a caller that wants to say ENOTFOUND should not have to find it in + * a sentence. Deliberately not `code`, which a handler writes a mark to + * so the panel does not report a failure the screen is already showing — + * and writes only while nothing holds that name yet. Declared here, the + * name is held whether or not anything is passed, so calling this `code` + * would stop that mark being written at all and tell the user twice. + */ + readonly systemCode?: string, + ) { + super(message, kind); + this.name = "SshError"; + } +} + +/** + * The fingerprint OpenSSH would print for the same key. + * + * Matching its format matters because the user checks this against something + * else — their provider's console, or `ssh-keyscan` — and two spellings of the + * same key read as two different keys. + */ +export function hostKeyFingerprint(key: Buffer): string { + return ( + "SHA256:" + + createHash("sha256").update(key).digest("base64").replace(/=+$/, "") + ); +} + +/** + * Decides whether to trust the server presenting this key. + * + * A parameter rather than a policy baked in here, because the answer differs by + * how we arrived. A server the user typed the address of has nothing to check + * against, so the honest answer is to show them the fingerprint. One Dyad has + * already looked at can be checked exactly. + * + * Synchronous: ssh2 decides during the handshake, with nothing to await into, + * so anything that needs asking is answered before connecting. + */ +export type HostKeyVerifier = (fingerprint: string) => boolean; + +/** Accepts any host, reporting the fingerprint. Trust on first use. */ +export function trustOnFirstUse( + onSeen: (fingerprint: string) => void, +): HostKeyVerifier { + return (fingerprint) => { + onSeen(fingerprint); + return true; + }; +} + +/** Accepts only the fingerprint a provider already told us to expect. */ +export function expectFingerprint(expected: string): HostKeyVerifier { + return (fingerprint) => fingerprint === expected; +} + +function classify( + err: NodeJS.ErrnoException & { level?: string }, + { connected = false }: { connected?: boolean } = {}, +): SshError { + const level = err.level ?? ""; + if (level === "client-authentication") { + return new SshError( + "auth-rejected", + "The server refused this key. Add Dyad's public key to the server's " + + "authorized_keys and try again.", + DyadErrorKind.Auth, + ); + } + if (level === "handshake") { + // Not the key being turned down: that is answered before this is asked, + // where the verifier said no. What is left is the two ends failing to + // agree — so this must not say the machine may have been swapped, which + // is what a user with an old sshd would otherwise be told. + return new SshError( + "handshake-failed", + // The library's own words, with its "Handshake failed:" preamble taken + // off — this sentence has already said that much. + `Dyad and this server could not agree on how to connect${ + err.message + ? `: ${err.message.replace(/^handshake failed:\s*/i, "")}` + : "" + }. That usually means the server's SSH is older or more restricted ` + + `than Dyad's defaults.`, + DyadErrorKind.External, + ); + } + if (level === "client-timeout") { + return new SshError( + "timeout", + connected + ? "The server stopped answering. It may have run out of memory or " + + "frozen; check it and try again." + : "The server did not answer in time. Check the address and that " + + "port 22 is reachable.", + DyadErrorKind.External, + ); + } + if ( + err.code === "ENOTFOUND" || + err.code === "ECONNREFUSED" || + err.code === "EHOSTUNREACH" + ) { + return new SshError( + "unreachable", + `Could not reach the server (${err.code}). Check the address and that ` + + `port 22 is open.`, + DyadErrorKind.External, + err.code, + ); + } + return new SshError( + "unknown", + connected + ? `The connection to the server failed: ${err.message}` + : `Could not connect over SSH: ${err.message}`, + DyadErrorKind.External, + err.code, + ); +} + +export interface SshResult { + code: number | null; + stdout: string; + stderr: string; +} + +export interface SshSession { + /** + * Runs a command, optionally feeding it on stdin. + * + * Input goes to stdin rather than into the command line because everything + * interesting here is a script, and a script on a command line has to survive + * a shell — quoting that is where this kind of code goes wrong. + */ + run( + command: string, + options?: { + input?: string; + onOutput?: (chunk: string) => void; + signal?: AbortSignal; + /** + * How long to wait for this command, in milliseconds. + * + * Unbounded by default, because an installer legitimately runs for + * minutes with nothing to say. Anything with a shorter honest answer — + * a probe, a tinker one-liner — should give one: without it, a server + * whose docker has wedged leaves the step it is on hanging forever, and + * the user has to work out for themselves that nothing is happening. + */ + timeoutMs?: number; + }, + ): Promise; + end(): void; +} + +/** + * Opens a session, or throws having said which part failed. + * + * The verifier runs before anything is sent, so a server that fails it never + * receives the credentials for the account being set up. + */ +export async function connectSsh( + target: SshTarget, + verifyHostKey: HostKeyVerifier, + { signal }: { signal?: AbortSignal } = {}, +): Promise { + // Loaded when someone actually connects, not when this module is imported. + // The setup handlers are registered during boot, so a value import here put + // ssh2 — and the optional native probe it runs on load — on the startup of + // every app, whether or not anyone ever set a server up. That also turned a + // packaging miss into a process that does not start, rather than one + // feature that does not work. + const { Client } = await import("ssh2"); + const conn = new Client(); + /** + * Why the connection died, for commands that were in flight when it did. + * + * The keepalive above turns a frozen peer into a torn-down socket, which + * closes any open channel with no exit code — and a command that reads that + * as an ordinary finish reports the connection's death as the installer's + * verdict. Kept here so the command can say what actually happened. + */ + let connectionError: SshError | null = null; + /** Whether the handshake got through, which changes what advice fits. */ + let ready = false; + let rejectedHostKey = false; + + // Registered before the handshake is awaited, not after: an error arriving + // in the same tick as "ready" would otherwise have nothing listening. The + // promise's own listener still owns rejecting while it is pending; this one + // only remembers, and keeps an unhandled 'error' on a Client — which would + // take the process down — from ever being unhandled. + conn.on("error", (err) => { + connectionError = classify( + err as NodeJS.ErrnoException & { level?: string }, + // Anything reaching here after the handshake is a session that was + // working and stopped, so the advice is about the machine rather than + // about the address that was typed. + { connected: ready }, + ); + }); + + await new Promise((resolve, reject) => { + // Reaching an address that is firewalled takes as long as the handshake + // timeout allows, and until this landed Cancel did nothing at all for + // that whole stretch — the panel said "Stopping…" and kept going. + if (signal?.aborted) { + conn.end(); + reject(new DyadError("Cancelled.", DyadErrorKind.UserCancelled)); + return; + } + const onAbort = () => { + conn.end(); + reject(new DyadError("Cancelled.", DyadErrorKind.UserCancelled)); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + const settled = (fn: () => void) => () => { + signal?.removeEventListener("abort", onAbort); + fn(); + }; + conn.on( + "ready", + settled(() => { + ready = true; + resolve(); + }), + ); + conn.on("error", (rawError) => { + signal?.removeEventListener("abort", onAbort); + const err = rawError; + // A host key the verifier turned down surfaces as a handshake failure, + // which is also what a genuinely mismatched key looks like. Saying which + // it was matters: one is the user declining, the other is a warning. + if (rejectedHostKey) { + reject( + new SshError( + "host-key-rejected", + "The server's identity was not accepted, so nothing was sent to it.", + DyadErrorKind.UserCancelled, + ), + ); + return; + } + reject(classify(err as NodeJS.ErrnoException & { level?: string })); + }); + + const config: ConnectConfig = { + host: target.host, + port: target.port ?? 22, + username: target.username, + privateKey: target.privateKey, + readyTimeout: CONNECT_TIMEOUT_MS, + // Only the handshake is bounded by readyTimeout. Without a keepalive + // nothing notices a peer that still answers TCP but has stopped + // answering SSH — a frozen box — and a command sent to it never + // settles, taking the whole setup with it. + keepaliveInterval: 15_000, + hostVerifier: (key: Buffer) => { + const accepted = verifyHostKey(hostKeyFingerprint(key)); + if (!accepted) rejectedHostKey = true; + return accepted; + }, + }; + conn.connect(config); + }); + + return { + run(command, { input, onOutput, signal, timeoutMs } = {}) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DyadError("Cancelled.", DyadErrorKind.UserCancelled)); + return; + } + let timer: ReturnType | undefined; + // Wired before the channel is asked for, not inside the callback + // that answers. Opening a channel takes a round trip, and on a server + // that has stopped answering that callback never runs — so a listener + // attached there would never exist, and Cancel would do nothing at + // all for as long as the command was outstanding. + let openStream: ClientChannel | null = null; + let cancelled = false; + const onAbort = () => { + cancelled = true; + stopListening(); + openStream?.close(); + reject(new DyadError("Cancelled.", DyadErrorKind.UserCancelled)); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + const stopListening = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + }; + + if (timeoutMs !== undefined) { + timer = setTimeout(() => { + // The channel is closed as well as the promise settled: giving up + // on an answer is not the same as stopping the work, and a + // command left running holds its channel open until the whole + // session ends. + cancelled = true; + openStream?.close(); + signal?.removeEventListener("abort", onAbort); + reject( + new SshError( + "command-timeout", + "The server did not answer in time.", + DyadErrorKind.External, + ), + ); + }, timeoutMs); + } + + const askForChannel = () => + conn.exec(command, (err, stream) => { + if (err) { + stopListening(); + // The latched reason first, as everywhere else: when the + // connection died, that is what went wrong, and the channel + // error is only how it surfaced here. + reject( + connectionError ?? + classify(err as NodeJS.ErrnoException & { level?: string }, { + connected: true, + }), + ); + return; + } + let stdout = ""; + let stderr = ""; + openStream = stream; + // The cancel landed while the channel was opening: the listener + // above has already rejected, so all this has to do is let go of + // the channel it was handed. + if (cancelled) { + stream.close(); + return; + } + + const failCommand = (error: unknown) => { + stopListening(); + reject( + connectionError ?? + classify( + error as NodeJS.ErrnoException & { level?: string }, + { + connected: true, + }, + ), + ); + }; + // A channel error with nobody listening is thrown, which would end + // the main process rather than the command. + stream.on("error", failCommand); + stream.stderr.on("error", failCommand); + + stream.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8"); + stdout += text; + onOutput?.(text); + }); + stream.stderr.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8"); + stderr += text; + onOutput?.(text); + }); + stream.on("close", (code: number | null) => { + stopListening(); + // Only when the command never said how it ended. A channel + // closed without an exit status is the connection dying under + // it, and reporting that as an exit code blames the installer + // for a lost link — but a command that did report its own + // result gets to keep it, even if the link died straight after. + // + // Loose equality on purpose: ssh2 initialises the exit code to + // undefined and only assigns it when an exit-status arrives, so + // "never said" is undefined here rather than null. + if (connectionError && code == null) { + reject(connectionError); + return; + } + resolve({ code: code ?? null, stdout, stderr }); + }); + + // Every command gets EOF on stdin, so one that reads it sees the + // end rather than a pipe that stays open for the life of the + // channel. `eof()` rather than `end()`: ending finishes the write + // side, and Node destroys a Duplex whose write side is finished as + // soon as its read side ends — which in ssh2 sends CHANNEL_CLOSE, + // dropping an exit status still on its way and killing a command + // still running. + if (input !== undefined) { + stream.write(input, () => stream.eof()); + } else { + stream.eof(); + } + }); + + try { + askForChannel(); + } catch (error) { + // ssh2 throws where it stands when the socket is already gone, + // which would skip the cleanup below and leave the abort listener + // attached to a signal nobody will ever answer. + stopListening(); + reject( + connectionError ?? + classify(error as NodeJS.ErrnoException & { level?: string }, { + connected: true, + }), + ); + } + }); + }, + end() { + try { + conn.end(); + } catch (error) { + // Closing a connection that already died is not a failure worth + // raising over whatever actually went wrong first. + logger.debug("Ignored error while closing SSH session", error); + } + }, + }; +} diff --git a/src/ipc/utils/telemetry.test.ts b/src/ipc/utils/telemetry.test.ts index 55840ecbb8..57b94e0fb9 100644 --- a/src/ipc/utils/telemetry.test.ts +++ b/src/ipc/utils/telemetry.test.ts @@ -35,6 +35,44 @@ describe("shouldFilterTelemetryException", () => { ).toBe(true); }); + it("does not report a server that could not be reached over SSH", async () => { + // A mistyped address or a firewalled port 22 is the user's own network, + // and the message carries whatever they typed. + const { SshError } = await import("@/ipc/utils/ssh_client"); + expect( + shouldFilterTelemetryException( + new SshError( + "unreachable", + "Could not reach the server (ENOTFOUND).", + DyadErrorKind.External, + ), + ), + ).toBe(true); + expect( + shouldFilterTelemetryException( + new SshError( + "timeout", + "The server did not answer in time.", + DyadErrorKind.External, + ), + ), + ).toBe(true); + }); + + it("still reports an SSH failure nothing here recognised", async () => { + // The bucket for what was not classified is the one worth hearing about. + const { SshError } = await import("@/ipc/utils/ssh_client"); + expect( + shouldFilterTelemetryException( + new SshError( + "unknown", + "Could not connect over SSH: something new", + DyadErrorKind.External, + ), + ), + ).toBe(false); + }); + it("filters RateLimitError 429s from retryWithRateLimit", () => { const error = new Error("Rate limited (429): Too Many Requests"); error.name = "RateLimitError"; @@ -137,6 +175,27 @@ describe("exceptions from a self-hosted instance", () => { expect(payload.ipc_channel).toBe("coolify:discover"); }); + it("redacts setting a server up, not only deploying to one", () => { + // Its failures quote the installer's own output, the server's address, and + // the address the user signs in with. The prefix differs from the deploy + // channels by one word, which is all it took to miss the filter. + sendTelemetryException( + new Error( + "Installing Coolify failed. The server said: connect ECONNRESET " + + "203.0.113.5:22 for someone@theirdomain.com", + ), + { ipc_channel: "coolify-setup:run" }, + ); + + const payload = sent.calls[0]; + // The stack header repeats the message, so the whole payload is checked + // rather than the message field alone. + expect(JSON.stringify(payload)).not.toContain("203.0.113.5"); + expect(JSON.stringify(payload)).not.toContain("theirdomain.com"); + expect(payload.exception_message).toBeUndefined(); + expect(payload.ipc_channel).toBe("coolify-setup:run"); + }); + it("keeps the message for every other channel", () => { sendTelemetryException(new Error("something broke"), { ipc_channel: "apps:list", diff --git a/src/ipc/utils/telemetry.ts b/src/ipc/utils/telemetry.ts index 5682115a16..54e00e71aa 100644 --- a/src/ipc/utils/telemetry.ts +++ b/src/ipc/utils/telemetry.ts @@ -6,6 +6,7 @@ import { } from "@/errors/dyad_error"; import { isGenericFetchFailedError } from "@/lib/posthogTelemetry"; import { TelemetryEventPayload } from "@/ipc/types"; +import { sshFailureOf } from "@/shared/ssh_failure"; import { COOLIFY_REQUEST_ERROR_NAME, COOLIFY_TRANSPORT_ERROR_NAME, @@ -79,11 +80,21 @@ export function sendTelemetryException( }); } -/** Channels that talk to a server the user runs, rather than to Dyad's own. */ +/** + * Channels that talk to a server the user runs, rather than to Dyad's own. + * + * Every prefix a self-hosted surface uses has to be listed. Setting a server up + * is the same class as deploying to one and carries more: its failures quote + * the installer's own output, the address, and the address the user signs in + * with. + */ +const SELF_HOSTED_CHANNEL_PREFIXES = ["coolify:", "coolify-setup:"]; + function isSelfHostedChannel(context?: Record): boolean { - return ( - typeof context?.ipc_channel === "string" && - context.ipc_channel.startsWith("coolify:") + const channel = context?.ipc_channel; + if (typeof channel !== "string") return false; + return SELF_HOSTED_CHANNEL_PREFIXES.some((prefix) => + channel.startsWith(prefix), ); } @@ -117,6 +128,16 @@ export function shouldFilterTelemetryException(error: unknown): boolean { return true; } + // The same thing again over SSH rather than HTTP: an address that does not + // answer, or a port nobody is listening on, is the user's own network being + // reported as a fault here — and the message carries whatever they typed. + // Only the two that say what went wrong. "unknown" is the bucket for a + // failure nothing here recognised, which is what telemetry is for. + const sshFailure = sshFailureOf(error); + if (sshFailure === "unreachable" || sshFailure === "timeout") { + return true; + } + if (error instanceof DyadError) { return isDyadErrorKindFilteredFromTelemetry(error.kind); } diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 508232cda1..69e9ec2519 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -343,6 +343,12 @@ export const queryKeys = { instanceUrl ?? "none", tokenId ?? "none", ] as const, + /** What the main process is doing with a server right now. */ + setup: ["coolify", "setup"] as const, + /** The public half of the key Dyad puts on servers it sets up. */ + serverKey: ["coolify", "serverKey"] as const, + /** What Dyad knows about signing in to the server it set up. */ + credentials: ["coolify", "credentials"] as const, /** Every instance's list, for invalidating after a token change. */ discoveryAll: ["coolify", "discovery"] as const, }, diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 5f96464467..6eb1527191 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -215,6 +215,45 @@ export type SupabaseOrganizationCredentials = z.infer< typeof SupabaseOrganizationCredentialsSchema >; +/** + * The admin account on a server Dyad set up itself. + * + * Its own shape rather than fields on the instance below, because it is a + * fact about a machine Dyad built rather than about the Coolify Dyad talks + * to. Usually the same server; not always. + */ +export const CoolifyAdminSchema = z.object({ + email: z.string(), + /** + * Optional only because it can become unreadable, never because it was not + * written: a keychain that cannot open it leaves the account behind without + * it. Required here instead would make the account itself vanish, and the + * ciphertext on disk has nowhere to be put back into once it has. + */ + password: SecretSchema.optional(), + /** + * The address of the server this account opens. + * + * There are two addresses stored, and they are usually the same one. This + * is the machine Dyad installed Coolify on. `instanceUrl` on the object + * below is the Coolify Dyad is currently talking to. + * + * They come apart in one case. Coolify has no API for making API tokens, so + * Dyad mints one through a workaround, and that workaround can fail. The + * install still succeeded, so the finished screen hands over this account + * and says to make a token by hand — and the next screen offers the token + * form with this address already filled in. Someone who instead points that + * form at a different Coolify they already had ends up with this account + * for one server and a token for another. + * + * Keeping the address next to the account is what lets the panel put each + * secret under the server it actually opens. One address over both would + * have to pick, and picking wrong shows this password under the other + * server's name, which reads as a way into it and is not one. + */ + instanceUrl: z.string(), +}); + /** * A Coolify instance Dyad can deploy to. * @@ -226,9 +265,49 @@ export type SupabaseOrganizationCredentials = z.infer< export const CoolifySchema = z.object({ instanceUrl: z.string().optional(), accessToken: SecretSchema.optional(), + /** + * The admin account on a server Dyad set up itself. + * + * Kept because Dyad invented this password on the user's behalf, for their + * own machine — showing it once and forgetting it leaves them locked out of + * a server they own. Encrypted like the token, and like the token it + * reaches the renderer whenever settings are read, not only when the panel + * asks to show it. + * + * One object rather than three fields, because they are only ever + * meaningful together: an account without the address it opens is a + * password for nothing, and they are written and forgotten at once. + */ + admin: CoolifyAdminSchema.optional(), }); export type Coolify = z.infer; +/** + * Every field of a Coolify, named and empty. + * + * writeSettings reads an absent key as a field some consumer read could not + * decrypt and hands the stored ciphertext back, so forgetting an instance by + * writing an empty object returns the token instead of clearing it. Only a + * key that is present and undefined reads as a deliberate clear. + * + * Typed so that a field added to CoolifySchema later fails to compile until + * it is named here too, which is what an empty object was reaching for. + * + * Built fresh each call rather than shared. writeSettings merges one level + * deep and then edits what it merged by path, so a single object handed to it + * is the object it edits — and one kept at module scope would carry another + * write's edits into every sign-out after it. + */ +export function forgottenCoolify(): { + [K in keyof Required]: undefined; +} { + return { + instanceUrl: undefined, + accessToken: undefined, + admin: undefined, + }; +} + export const SupabaseSchema = z.object({ // Map keyed by organizationSlug -> organization credentials organizations: z diff --git a/src/main/settings.test.ts b/src/main/settings.test.ts index 29c71c3e07..96347a1ad8 100644 --- a/src/main/settings.test.ts +++ b/src/main/settings.test.ts @@ -21,7 +21,7 @@ import { rewriteRecoveredSafeStorageSecretsAfterKeychainUnlock, } from "@/main/settings"; import { getUserDataPath } from "@/paths/paths"; -import { UserSettings } from "@/lib/schemas"; +import { forgottenCoolify, UserSettings } from "@/lib/schemas"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { getRemoteDesktopConfig } from "@/ipc/shared/remote_desktop_config"; import { @@ -1120,6 +1120,155 @@ describe("preserving undecryptable secrets", () => { }); }); + it("hides a Coolify token that will not decrypt", () => { + // Handing the ciphertext through would have it sent to Coolify as the + // token, and it would be rejected with no way to tell why. + store[mockSettingsPath] = JSON.stringify({ + coolify: { + instanceUrl: "http://203.0.113.5:8000", + accessToken: lockedSecret("coolify"), + }, + }); + + const read = readSettings(); + expect(read.coolify?.accessToken).toBeUndefined(); + expect(read.coolify?.instanceUrl).toBe("http://203.0.113.5:8000"); + }); + + it("puts the Coolify admin password through encryption", () => { + // Dyad made this one up and is the only thing holding it, which is a + // reason to keep it readable and not a reason to keep it in the clear. + writeSettings({ + coolify: { + instanceUrl: "http://203.0.113.5:8000", + admin: { + email: "me@gmail.com", + password: { value: "invented-password" }, + instanceUrl: "http://203.0.113.5:8000", + }, + }, + }); + + expect(readStoredFile().coolify.admin).toEqual({ + email: "me@gmail.com", + password: { value: "invented-password", encryptionType: "plaintext" }, + instanceUrl: "http://203.0.113.5:8000", + }); + }); + + it("forgets a Coolify token that will not decrypt when signing out", () => { + // The preservation pass puts a secret back whenever the container it + // lives in is still there, treating an absent key as a consumer read that + // could not decrypt it rather than as a clear. An empty coolify object is + // still a container, so the one write whose whole job is to forget the + // instance would hand the token straight back. + store[mockSettingsPath] = JSON.stringify({ + coolify: { + instanceUrl: "http://203.0.113.5:8000", + accessToken: lockedSecret("coolify"), + }, + }); + + writeSettings({ coolify: forgottenCoolify() }); + + expect(readStoredFile().coolify).toEqual({}); + }); + + it("clears again on a second sign-out", () => { + // writeSettings edits the object it was handed, so a shape kept at module + // scope would carry one write's edits into the next sign-out. + store[mockSettingsPath] = JSON.stringify({ + coolify: { + instanceUrl: "http://203.0.113.5:8000", + accessToken: lockedSecret("coolify"), + }, + }); + writeSettings({ coolify: forgottenCoolify() }); + expect(readStoredFile().coolify).toEqual({}); + + store[mockSettingsPath] = JSON.stringify({ + coolify: { + instanceUrl: "http://203.0.113.5:8000", + accessToken: lockedSecret("coolify"), + }, + }); + writeSettings({ coolify: forgottenCoolify() }); + expect(readStoredFile().coolify).toEqual({}); + }); + + it("keeps a locked Coolify admin password across an unrelated write", () => { + // The preservation path hands the ciphertext back after encryption, having + // deleted it from the merged settings first. Reading the account and then + // reaching for its password without checking finds nothing there — and + // every settings write, Coolify or not, dies on it. + const locked = lockedSecret("coolify"); + store[mockSettingsPath] = JSON.stringify({ + coolify: { + instanceUrl: "http://203.0.113.5:8000", + admin: { + email: "me@gmail.com", + password: locked, + instanceUrl: "http://203.0.113.5:8000", + }, + }, + }); + + writeSettings({ enableAutoUpdate: false }); + + expect(readStoredFile().enableAutoUpdate).toBe(false); + expect(readStoredFile().coolify.admin.password).toEqual(locked); + }); + + it("keeps a locked admin password when a Coolify write rebuilds the object without it", () => { + // What `coolify:save-token` does: spread what readSettings could decrypt, + // then add the token. The account came back without its password, so + // without the container still standing there is nowhere to put the + // ciphertext back and the only copy of it goes. + const locked = lockedSecret("coolify"); + store[mockSettingsPath] = JSON.stringify({ + coolify: { + instanceUrl: "http://203.0.113.5:8000", + admin: { + email: "me@gmail.com", + password: locked, + instanceUrl: "http://203.0.113.5:8000", + }, + }, + }); + + writeSettings({ + coolify: { + ...readSettings().coolify, + instanceUrl: "http://203.0.113.5:8000", + accessToken: { value: "1|fresh" }, + }, + }); + + expect(readStoredFile().coolify.admin.password).toEqual(locked); + expect(readStoredFile().coolify.admin.email).toBe("me@gmail.com"); + }); + + it("hides an admin password that will not decrypt, keeping the account", () => { + // Handing the ciphertext through would put it on screen as the password, + // and it would not open anything. The account it belongs to stays, so a + // later write still has somewhere to put the ciphertext back. + store[mockSettingsPath] = JSON.stringify({ + coolify: { + instanceUrl: "http://203.0.113.5:8000", + admin: { + email: "me@gmail.com", + password: lockedSecret("coolify"), + instanceUrl: "http://203.0.113.5:8000", + }, + }, + }); + + const read = readSettings(); + expect(read.coolify?.admin?.password).toBeUndefined(); + expect(read.coolify?.admin?.email).toBe("me@gmail.com"); + expect(read.coolify?.admin?.instanceUrl).toBe("http://203.0.113.5:8000"); + }); + it("preserves a locked provider apiKey when a write rebuilds providerSettings without it", () => { const locked = lockedSecret("openai"); store[mockSettingsPath] = JSON.stringify({ diff --git a/src/main/settings.ts b/src/main/settings.ts index 1403064de2..987a6befad 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -413,6 +413,19 @@ export function writeSettings(settings: Partial): void { accessToken: encrypt(newSettings.coolify.accessToken.value), }; } + // Guarded on the password rather than the account, because the two do not + // arrive together: the preservation pass above strips a password it means + // to write back verbatim, leaving the account here with none. + const coolifyAdmin = newSettings.coolify?.admin; + if (coolifyAdmin?.password) { + newSettings.coolify = { + ...newSettings.coolify, + admin: { + ...coolifyAdmin, + password: encrypt(coolifyAdmin.password.value), + }, + }; + } if (newSettings.supabase) { // Encrypt legacy tokens (kept for backwards compat) if (newSettings.supabase.accessToken) { @@ -685,6 +698,29 @@ function readExistingSettingsFile( combinedSettings.coolify = rest; } } + const admin = combinedSettings.coolify?.admin; + const adminPassword = admin?.password; + if (admin && adminPassword) { + const resolved = resolveStoredSecret( + adminPassword, + "Coolify admin password", + ["coolify", "admin", "password"], + ctx, + ); + if (resolved) { + combinedSettings.coolify = { + ...combinedSettings.coolify, + admin: { ...admin, password: resolved }, + }; + } else { + // Only the password. The account stays so that the ciphertext has a + // container to be written back into on the next write — dropping it + // whole reads to the write path as the user having deleted the account, + // and throws away a password a repaired keychain could still open. + const { password: _dropped, ...rest } = admin; + combinedSettings.coolify = { ...combinedSettings.coolify, admin: rest }; + } + } for (const provider in combinedSettings.providerSettings) { if (combinedSettings.providerSettings[provider].apiKey) { const resolved = resolveStoredSecret( diff --git a/src/shared/coolify_admin_email.test.ts b/src/shared/coolify_admin_email.test.ts new file mode 100644 index 0000000000..dd4f4dd6f0 --- /dev/null +++ b/src/shared/coolify_admin_email.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { adminEmailRefusal } from "./coolify_admin_email"; + +describe("reserved domains", () => { + it("refuses a subdomain of a documentation domain", () => { + // RFC 2606 reserves everything under these, so Coolify's seeder will + // refuse the address minutes into an install. + expect(adminEmailRefusal("admin@mail.example.com")).not.toBeNull(); + expect(adminEmailRefusal("admin@notexample.com")).toBeNull(); + }); +}); + +describe("addresses Coolify would turn down", () => { + // Coolify resolves the domain, so these fail on the server however + // well-formed they look. Catching them here means the user finds out while + // typing rather than after a multi-minute install that seeds nothing. + it.each([ + ["admin@dyad.test", false, "a reserved TLD that cannot resolve"], + ["admin@my.localhost", false, "reserved for loopback"], + ["admin@thing.invalid", false, "reserved to always fail"], + ["admin@example.com", false, "reserved for documentation"], + ["admin", false, "not an address at all"], + ["admin@nodomain", false, "no dot, so no resolvable domain"], + ["admin@ dyad.sh", false, "a space is not allowed"], + ["someone@gmail.com", true, "an ordinary address"], + ["dev+coolify@sub.domain.co.uk", true, "tagging and subdomains are fine"], + ])("reads %s as %s (%s)", (email, usable) => { + expect(adminEmailRefusal(email) === null).toBe(usable); + }); + + it("does not reject a domain merely for containing a reserved word", () => { + // The check looks at the last label; a stricter rule would turn away + // addresses that work perfectly well. + expect(adminEmailRefusal("admin@test-lab.com")).toBeNull(); + expect(adminEmailRefusal("admin@example-corp.io")).toBeNull(); + }); +}); + +describe("adminEmailRefusal", () => { + it("does not call an address Dyad cannot send undeliverable", () => { + // `!` and `#` are legal in a local part, so this address may work + // perfectly well — the refusal is Dyad's, and saying the domain does not + // resolve sends the user to check something that was never wrong. + const refusal = adminEmailRefusal("will!s@gmail.com"); + + expect(refusal).toMatch(/can't send/); + expect(refusal).not.toMatch(/receive mail at/); + }); + + it("names every character it turns an address down for", () => { + // The message lists them, so the list has to be the one the check uses. + // Being told to remove a character you did not type leaves you with + // nothing to change, which is the whole of what this message is for. + const refusal = adminEmailRefusal("will!s@gmail.com") ?? ""; + for (const named of ["quote", "backslash", "`", "$", "#", "!"]) { + expect(refusal).toContain(named); + } + + // And every one of them really does land on that message rather than on + // the one about mail. A newline cannot reach it — the shape check reads + // it as whitespace first — so it is not among these. + for (const address of [ + "will's@gmail.com", + 'will"s@gmail.com', + "will\\s@gmail.com", + "will`s@gmail.com", + "will$s@gmail.com", + "will#s@gmail.com", + "will!s@gmail.com", + ]) { + expect(adminEmailRefusal(address)).toBe(refusal); + } + }); + + it("does not blame characters for an address that is not one yet", () => { + // The branch a half-typed address lands on, which is most of the + // keystrokes anyone makes here — so the wrong message on it is the one a + // user sees most. `foo..com` is the same: a shape Coolify will not + // resolve, not a character Dyad cannot send. + const undeliverable = adminEmailRefusal("admin@dyad.test"); + + expect(adminEmailRefusal("adm")).toBe(undeliverable); + expect(adminEmailRefusal("admin@nodomain")).toBe(undeliverable); + expect(adminEmailRefusal("admin@foo..com")).toBe(undeliverable); + // Reserved for documentation, which is a fact about where mail goes and + // not about anything Dyad cannot send. + expect(adminEmailRefusal("admin@example.com")).toBe(undeliverable); + expect(adminEmailRefusal("admin@mail.example.net")).toBe(undeliverable); + }); + + it("still says what a domain nobody can reach is", () => { + expect(adminEmailRefusal("admin@dyad.test")).toMatch(/receive mail at/); + }); + + it("says nothing about an address it takes", () => { + expect(adminEmailRefusal("me@gmail.com")).toBeNull(); + }); +}); diff --git a/src/shared/coolify_admin_email.ts b/src/shared/coolify_admin_email.ts new file mode 100644 index 0000000000..b103b784f8 --- /dev/null +++ b/src/shared/coolify_admin_email.ts @@ -0,0 +1,64 @@ +const UNDELIVERABLE = + "Use an email address you can receive mail at. Coolify checks that the " + + "domain resolves when it creates the admin account."; + +const UNSENDABLE = + "Dyad can't send an address containing quotes, backslashes or ` $ # ! to " + + "the server. Try one without them."; + +/** + * Why this address cannot be the admin account, in words for the person who + * typed it, or null where it can. Not always Coolify's answer: one of the two + * rules below is Dyad's own limit, and an address it refuses may be one + * Coolify would have taken. + * + * Lives in shared/ so the panel can warn while the user is still typing and + * the handler can refuse before it starts, without the two drifting apart. + * Getting the domain wrong is expensive in a way most validation is not: + * Coolify checks it when it seeds the account, minutes into an install, and a + * rejected address leaves a finished install with no account on it. + * + * Two different rules refuse an address here and they are not the same news. + * One is about where mail goes; the other is about what Dyad can put in a + * shell command. Saying the first for both told someone whose address does + * work that they could not receive mail at it, which is not true and leaves + * them nothing to change. + * + * Kept free of any Node import — the renderer imports this, and a `crypto` + * import here would take the whole window down with it. + */ +export function adminEmailRefusal(email: string): string | null { + const trimmed = email.trim(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return UNDELIVERABLE; + // The address goes to the server inside a quoted shell word and from there + // into a .env file, and buildInstallScript refuses every character that + // could break either — so the set is wider than the quoting alone would + // need. Said here too, so it is said while the address is being typed + // rather than after Dyad has connected and looked the server over. + // + // Some of these are legal in a local part, so this is Dyad's limit rather + // than the address being wrong — and it says so, because telling someone + // their working address is undeliverable sends them to fix the wrong thing. + if (/['"\\`$\n\r#!]/.test(trimmed)) return UNSENDABLE; + // One trailing dot is a legal way to write an absolute name, and Coolify + // resolves the same domain either way — so it is removed before the checks + // below rather than letting `dyad.test.` past the reserved list. + const domain = trimmed + .slice(trimmed.lastIndexOf("@") + 1) + .toLowerCase() + .replace(/\.$/, ""); + // Every label has to be a label. `foo..com` and `.com` are not addresses + // Coolify will accept, and finding that out costs the whole install. Said + // as "not empty" rather than as an alphabet, so an internationalised domain + // is left alone. + if (!/^[^\s.@]+(\.[^\s.@]+)+$/.test(domain)) return UNDELIVERABLE; + // Reserved by RFC 2606 and RFC 6761 for testing and documentation, so none + // of them resolve and none of them can ever be accepted. + const reserved = ["test", "example", "invalid", "localhost", "local"]; + const lastLabel = domain.slice(domain.lastIndexOf(".") + 1); + if (reserved.includes(lastLabel)) return UNDELIVERABLE; + const documentation = ["example.com", "example.net", "example.org"]; + return documentation.some((d) => domain === d || domain.endsWith(`.${d}`)) + ? UNDELIVERABLE + : null; +} diff --git a/src/shared/coolify_domain.test.ts b/src/shared/coolify_domain.test.ts new file mode 100644 index 0000000000..f8a1aff5e9 --- /dev/null +++ b/src/shared/coolify_domain.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { isPlausibleInstanceDomain } from "./coolify_domain"; + +/** + * Pinned directly, not only through the panel and the script builder. + * + * What this decides ends up inside a command that runs as root on the user's + * server, so the characters it lets through are the point — and a rule with + * no test of its own is a rule that can be widened without anyone noticing. + */ +describe("what Coolify will be given as a domain", () => { + it("takes a name however the user pasted it", () => { + for (const written of [ + "coolify.example.com", + "https://coolify.example.com", + "http://coolify.example.com", + "https://coolify.example.com/", + " coolify.example.com ", + ]) { + expect(isPlausibleInstanceDomain(written)).toBe(true); + } + }); + + it("refuses anything that could end the quoting around it", () => { + // Each of these would leave the script saying something other than what + // it was built to say. Refused rather than escaped: escaping is a thing + // to get subtly wrong once, on someone else's machine, as root. + for (const hostile of [ + "coolify.example.com'", + 'coolify.example.com"', + "coolify.example.com`id`", + "coolify.example.com$(id)", + "coolify.example.com;id", + "coolify.example.com id", + "coolify.example.com\nid", + "coolify.example.com\\", + "coolify.example.com&&id", + "coolify.example.com|id", + "coolify.example.com#", + "$HOME.example.com", + ]) { + expect( + isPlausibleInstanceDomain(hostile), + `${JSON.stringify(hostile)} should be refused`, + ).toBe(false); + } + }); + + it("refuses a value with nothing left once the scheme is off", () => { + for (const empty of ["", " ", "https://", "https:///"]) { + expect(isPlausibleInstanceDomain(empty)).toBe(false); + } + }); +}); diff --git a/src/shared/coolify_domain.ts b/src/shared/coolify_domain.ts new file mode 100644 index 0000000000..4e001bdfb8 --- /dev/null +++ b/src/shared/coolify_domain.ts @@ -0,0 +1,22 @@ +/** + * Whether a domain can be handed to Coolify's own configuration. + * + * The characters are what makes this narrow rather than the shape: the value + * ends up inside a script that runs on the user's server, so anything that + * could end the quoting around it is refused rather than escaped. + * + * Lives in shared/ so the panel can say so while the domain is being typed and + * the code that builds the script can refuse the same thing — that refusal is + * a guard, and a guard is not a good place to learn you made a typo, since it + * fires minutes into an install. + * + * Free of any Node import, because the renderer imports it. + */ +export function isPlausibleInstanceDomain(value: string): boolean { + const bare = value + .trim() + .replace(/^https?:\/\//, "") + .replace(/\/+$/, ""); + if (!bare) return false; + return /^[A-Za-z0-9.-]+$/.test(bare); +} diff --git a/src/shared/coolify_scopes.test.ts b/src/shared/coolify_scopes.test.ts new file mode 100644 index 0000000000..365761b5b2 --- /dev/null +++ b/src/shared/coolify_scopes.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + COOLIFY_REQUIRED_SCOPES, + COOLIFY_SCOPES, + COOLIFY_SCOPES_PHP_ARRAY, +} from "./coolify_scopes"; + +describe("the scopes Dyad asks for", () => { + it("asks for exactly these four", () => { + // Written out rather than derived. Every other assertion about scopes in + // the repo comes from this array, so only a literal notices the array + // itself losing an entry — dropping `write` here would otherwise pass the + // whole suite and fail the first deploy. + expect(COOLIFY_SCOPES).toEqual([ + "read", + "read:sensitive", + "write", + "deploy", + ]); + // The two spellings are written out for the same reason. One is read by a + // user ticking boxes; the other is interpolated into a script that mints a + // token on their server, where a well-formed but wrong list would be taken + // at face value. + expect(COOLIFY_REQUIRED_SCOPES).toBe("read, read:sensitive, write, deploy"); + expect(COOLIFY_SCOPES_PHP_ARRAY).toBe( + "['read', 'read:sensitive', 'write', 'deploy']", + ); + }); + + it("keeps read:sensitive, which the deployment log is hidden behind", () => { + // Coolify's api.sensitive middleware hides a deployment's `logs` and an + // application's `private_key_id` without it, and the deploy path reads + // both. Dropping it costs the build output on every failed deploy, and + // nothing fails — the field simply stops arriving. + expect(COOLIFY_SCOPES).toContain("read:sensitive"); + }); + + it("does not ask for root", () => { + // Coolify treats root as a bypass of the ability check rather than as a + // set of abilities, and no route asks for it. + expect(COOLIFY_REQUIRED_SCOPES).not.toContain("root"); + }); +}); diff --git a/src/shared/coolify_scopes.ts b/src/shared/coolify_scopes.ts index 1d18b5039c..7c3d9aea02 100644 --- a/src/shared/coolify_scopes.ts +++ b/src/shared/coolify_scopes.ts @@ -2,8 +2,26 @@ * Every scope the integration needs, in the order Coolify's token form lists * them. * - * Lives in shared/ so the setup instructions in the renderer and the 403 - * message in the main process cannot drift apart: Coolify fixes a token's - * scopes when it is created, so a token missing one has to be recreated. + * Lives in shared/ so the setup instructions in the renderer, the 403 message + * in the main process, and the token Dyad mints for itself cannot drift apart: + * Coolify fixes a token's scopes when it is created, so a token missing one + * has to be recreated. + * + * `read:sensitive` earns its place — Coolify hides a deployment's `logs` and an + * application's `private_key_id` without it, and the deploy path reads both. + * `root` is not here: Coolify treats that as a bypass of the ability check + * rather than as a set of abilities, and no route asks for it. */ -export const COOLIFY_REQUIRED_SCOPES = "read, read:sensitive, write, deploy"; +export const COOLIFY_SCOPES = [ + "read", + "read:sensitive", + "write", + "deploy", +] as const; + +export const COOLIFY_REQUIRED_SCOPES = COOLIFY_SCOPES.join(", "); + +/** The same list as the PHP array literal the tinker script needs. */ +export const COOLIFY_SCOPES_PHP_ARRAY = `[${COOLIFY_SCOPES.map( + (scope) => `'${scope}'`, +).join(", ")}]`; diff --git a/src/coolify_deploy/domain_check.test.ts b/src/shared/domain_check.test.ts similarity index 100% rename from src/coolify_deploy/domain_check.test.ts rename to src/shared/domain_check.test.ts diff --git a/src/coolify_deploy/domain_check.ts b/src/shared/domain_check.ts similarity index 97% rename from src/coolify_deploy/domain_check.ts rename to src/shared/domain_check.ts index ffb1f0f417..f70ede58cd 100644 --- a/src/coolify_deploy/domain_check.ts +++ b/src/shared/domain_check.ts @@ -1,5 +1,13 @@ import { isIP } from "node:net"; +/** + * Reading DNS answers, for whoever needs to. + * + * Lives in shared/ rather than beside the deploy machine: the setup flow asks + * the same question about a server's own domain, and one machine reaching into + * another's directory is what the boundary check exists to stop. + */ + /** What Coolify reports as the address of the machine it runs on. */ const DOCKER_HOST_ALIAS = "host.docker.internal"; diff --git a/src/shared/ssh_failure.test.ts b/src/shared/ssh_failure.test.ts new file mode 100644 index 0000000000..1a7e40914c --- /dev/null +++ b/src/shared/ssh_failure.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { sshFailureOf } from "./ssh_failure"; + +describe("reading a failure off an error", () => { + it("agrees with the error the client actually throws", async () => { + // Matched by name so that asking costs no load-time dependency, which + // means the name is a contract between two files that do not import one + // another. Renaming it there would quietly stop every check here. + const { SshError } = await import("@/ipc/utils/ssh_client"); + const { DyadErrorKind } = await import("@/errors/dyad_error"); + + expect( + sshFailureOf( + new SshError("unreachable", "nothing answered", DyadErrorKind.External), + ), + ).toBe("unreachable"); + expect( + sshFailureOf( + new SshError("command-timeout", "too slow", DyadErrorKind.External), + ), + ).toBe("command-timeout"); + }); + + it("says nothing about errors that are not the client's", () => { + expect(sshFailureOf(new Error("plain"))).toBeNull(); + expect(sshFailureOf(null)).toBeNull(); + expect(sshFailureOf(undefined)).toBeNull(); + expect( + sshFailureOf({ name: "SshError", failure: "unreachable" }), + ).toBeNull(); + }); + + it("does not hand back a failure that is not one of ours", () => { + // The name is all that got us this far, and anything can carry it. + // Answering with an unrecognised string would put a value past a caller + // that has covered every case the type admits. + const odd = Object.assign(new Error("odd"), { + name: "SshError", + failure: "made-up", + }); + expect(sshFailureOf(odd)).toBeNull(); + }); + + it("does not answer for one of ours carrying no failure", () => { + // A shape that passes the name check but has nothing to read. + const odd = Object.assign(new Error("odd"), { name: "SshError" }); + expect(sshFailureOf(odd)).toBeNull(); + }); +}); diff --git a/src/shared/ssh_failure.ts b/src/shared/ssh_failure.ts new file mode 100644 index 0000000000..a7d7f35e01 --- /dev/null +++ b/src/shared/ssh_failure.ts @@ -0,0 +1,65 @@ +/** + * What went wrong on an SSH attempt, and how to ask about it from anywhere. + * + * Separate from the client so that asking costs nothing: telemetry is reached + * from `main.ts` and from every typed handler, and it only ever needs to know + * which failure an error carries. The client itself is a large module whose + * one job is to talk to a server, and nothing on the startup path has any use + * for that. `connectSsh` loads `ssh2` when it actually connects, which is what + * keeps it off the boot; this keeps the question askable without reaching for + * the client at all. + */ +/** + * Every failure the client reports, and the only source of the type. + * + * One list rather than a union beside a lookup: the accessor below has to + * decide whether a value is one of these at runtime, and a second copy of + * the members is a copy that can fall behind the first. + */ +export const SSH_FAILURES = [ + "auth-rejected", + "host-key-rejected", + "unreachable", + /** The connection stopped answering: nothing on it will work again. */ + "timeout", + /** + * We gave up on one command, having asked it to be quick. + * + * Distinct from "timeout" because the connection is still good and the + * question can be asked again — a caller that polls must be able to tell + * "this attempt was slow" from "this link is dead", or one slow answer ends + * a wait that had minutes left in it. + */ + "command-timeout", + /** + * The handshake failed for a reason that is not the key being turned down. + * + * Key exchange or host-key algorithms with nothing in common, or a + * protocol error. Kept apart from "host-key-rejected" because that one + * says the server may not be the machine it was — which is alarming, and + * wrong for an old or hardened sshd that simply cannot agree on ciphers. + */ + "handshake-failed", + /** Nothing here recognised it. */ + "unknown", +] as const; + +export type SshFailure = (typeof SSH_FAILURES)[number]; + +/** + * The failure an error carries, or null if it is not one of ours. + * + * Matched on the name rather than with `instanceof` so that asking the + * question costs nothing at load time. `SshError` sets its own `name`, and + * the failure is one of the values above. + */ +export function sshFailureOf(error: unknown): SshFailure | null { + if (!(error instanceof Error) || error.name !== "SshError") return null; + const { failure } = error as Error & { failure?: unknown }; + // Checked, not asserted. The name is all that got us here, and anything + // may carry it — handing back a value the type says cannot exist would + // put it past a caller that has covered every case there is. + return SSH_FAILURES.includes(failure as SshFailure) + ? (failure as SshFailure) + : null; +} diff --git a/src/state_machines/boundaries.test.ts b/src/state_machines/boundaries.test.ts index c401c74c33..f40c1ca1fb 100644 --- a/src/state_machines/boundaries.test.ts +++ b/src/state_machines/boundaries.test.ts @@ -20,6 +20,7 @@ const MACHINE_DIRECTORIES = [ "version_preview", "voice_to_text", "user_input", + "coolify_setup", ] as const; type MachineDirectory = (typeof MACHINE_DIRECTORIES)[number]; type BoundaryRule = diff --git a/src/testing/fake_ssh_server.ts b/src/testing/fake_ssh_server.ts new file mode 100644 index 0000000000..c66a62f99f --- /dev/null +++ b/src/testing/fake_ssh_server.ts @@ -0,0 +1,288 @@ +import ssh2 from "ssh2"; +import type { Connection } from "ssh2"; + +// Destructured from the default import: ssh2 is CommonJS, and named imports +// off it work under a bundler but not under plain Node ESM. +const { Server, utils } = ssh2; + +/** + * A server Dyad can install Coolify onto, enough of one to drive the flow. + * + * Started by the spec rather than inside the fake HTTP server, so a test can + * read what was asked of it directly instead of through a control endpoint. + * + * The install path speaks SSH rather than HTTP, so the fake Coolify beside + * this one cannot answer it. Dyad sends a small, fixed set of commands, and + * this answers them the way a real box would — down to details the parser + * depends on, like a tinker transcript echoing the script back with a "> " + * prompt before the output arrives. + * + * Nothing here validates the key it is offered. Whether Dyad's key reaches a + * server is the user's own step and there is nothing to check it against; what + * matters for a test is that a key is offered at all, which is asserted by + * refusing a connection that offers none. + */ + +/** + * An ed25519 key ssh2 will accept. + * + * Its own generator writes the public point without its leading byte when that + * byte happens to be zero — one key in 256 — and its own parser then rejects + * what it wrote. Unchecked, that is a suite which fails a few runs in a + * thousand with a parse error nothing in the diff explains. Retried rather + * than replaced with a fixed key, which would mean a private key in a public + * repository. + */ +export function generateSshKeyPair(): { private: string; public: string } { + for (let attempt = 0; attempt < 8; attempt++) { + const pair = utils.generateKeyPairSync("ed25519"); + if (!(utils.parseKey(pair.private) instanceof Error)) return pair; + } + throw new Error("ssh2 generated 8 unusable ed25519 keys in a row."); +} + +const START = "__DYAD_OUT_START__"; +const END = "__DYAD_OUT_END__"; + +interface FakeServerBehaviour { + /** What the batched probe reports. Defaults to a healthy empty machine. */ + probe?: string; + /** Exit code for install.sh. Non-zero makes the install fail. */ + installExit?: number; + /** Coolify's reported version, which decides the automatic token path. */ + version?: string; + /** + * Reports the exit status this long after closing the command's output. + * + * A real sshd sends EOF when the command's stdout closes and the exit + * status when the process is reaped, and those are not the same moment. + * Answering both in one batch — which is what this fake does otherwise — + * hides every bug that depends on the gap. + */ + exitAfterEofMs?: number; +} + +interface FakeServerState extends FakeServerBehaviour { + installed: boolean; + /** Every command the flow sent, so a test can say what was asked. */ + commands: string[]; + keyOffered: boolean; +} + +/** + * A tinker transcript, shaped the way a real one is. + * + * tinker echoes every line it is fed with a "> " prompt, and the output of a + * statement lands on the line after the prompt that produced it — so the + * opening marker arrives with a prompt attached. + * + * The prompt is emitted because a real transcript carries one, not because + * the parser demands it: that tolerates the prompt being absent or repeated, + * since which of those psysh prints is its own business. A fake that dropped + * it would simply stop modelling what a real one sends. + */ +function transcript(script: string, output: string): string { + const echoed = script + .split(/\r?\n/) + .map((line) => `> ${line}`) + .join("\n"); + return [ + "Psy Shell v0.11 (PHP 8.2) by Justin Hileman", + echoed, + `> ${START}`, + output, + END, + "", + ].join("\n"); +} + +/** + * The environment a `docker exec -e NAME='value'` command carries. + * + * Modelled because the scripts read their inputs with getenv() rather than + * having them interpolated — that is the whole point of sending them that + * way — so a fake that ignores it cannot tell a script reading the right + * variable from one reading nothing at all. + */ +function environmentOf(command: string): Record { + const env: Record = {}; + const pattern = /-e ([A-Z_][A-Z0-9_]*)='([^']*)'/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(command)) !== null) { + env[match[1]] = match[2]; + } + return env; +} + +/** What the fake answers for one tinker script. */ +function answerTinker( + script: string, + env: Record, + state: FakeServerState, +): string { + if (script.includes("constants.coolify.version")) { + return state.version ?? "4.3.2"; + } + if (script.includes("is_api_enabled")) { + return "enabled"; + } + if (script.includes("->exists()")) { + // Answered about the address the script actually asked about. A script + // that read no variable, or the wrong one, gets told there is no account. + if (!env.DYAD_ADMIN_EMAIL) return "no"; + return state.installed ? "yes" : "no"; + } + if (script.includes("createToken")) { + if (!env.DYAD_ADMIN_EMAIL) return "no-user"; + // Sanctum's shape: an id, a pipe, then 40+ alphanumerics. Dyad checks + // that before storing it, so a token that merely looks token-ish is + // rejected — as a stray warning line should be. + return "1|EcaUxT43T5fgdLJmnYj0702tEUC6viy5jEhO3Ujk2298db95"; + } + if (script.includes("setupDynamicProxyConfiguration")) { + return "applied"; + } + return ""; +} + +function answer( + command: string, + stdin: string, + state: FakeServerState, +): { + stdout: string; + code: number; +} { + state.commands.push(command); + + if (command.includes("MemTotal")) { + return { + stdout: + state.probe ?? + `mem=1967\ncontainer=${state.installed ? "coolify" : ""}\nbusy=no\n`, + code: 0, + }; + } + if (command.includes("install.sh")) { + const code = state.installExit ?? 0; + if (code === 0) state.installed = true; + return { + stdout: "1/6 Installing Docker...\n6/6 Coolify is up\n", + code, + }; + } + if (command.includes("db:seed")) { + return { stdout: "Seeding: RootUserSeeder\n", code: 0 }; + } + if (command.includes("tinker")) { + // The script arrives on stdin, which is the whole reason it does: a + // script on a command line has to survive a shell as well as PHP. + return { + stdout: transcript( + stdin, + answerTinker(stdin, environmentOf(command), state), + ), + code: 0, + }; + } + return { stdout: "", code: 0 }; +} + +export interface FakeSshServer { + port: number; + state: FakeServerState; + close: () => Promise; +} + +export async function startFakeSshServer(): Promise { + const state: FakeServerState = { + installed: false, + commands: [], + keyOffered: false, + }; + + const open = new Set(); + const server = new Server( + // Generated per server: a fixed key checked into the repo would be a + // private key in a public repository, however inert. + { hostKeys: [generateSshKeyPair().private] }, + (client: Connection) => { + open.add(client); + client.on("close", () => open.delete(client)); + client.on("error", () => open.delete(client)); + client.on("authentication", (ctx) => { + if (ctx.method === "publickey") { + state.keyOffered = true; + ctx.accept(); + return; + } + // Anything else is refused, so a test can prove a key was offered. + ctx.reject(["publickey"]); + }); + + client.on("ready", () => { + client.on("session", (accept) => { + const session = accept(); + session.on("exec", (acceptExec, _reject, info) => { + const stream = acceptExec(); + let stdin = ""; + stream.on("data", (chunk: Buffer) => { + stdin += chunk.toString("utf8"); + }); + const finish = () => { + const { stdout, code } = answer(info.command, stdin, state); + stream.write(stdout); + if (!state.exitAfterEofMs) { + stream.exit(code); + stream.end(); + return; + } + // Output first, then the status, with a gap — a client that + // closes the channel on EOF loses the status, and against a real + // sshd would kill a command that was still running. Sent through + // the protocol because ssh2's own exit() refuses once the write + // side is done, which is not a restriction sshd has. Private + // API, written against ssh2 1.17.0: an upgrade that breaks this + // is this line rather than the product. + stream.eof(); + setTimeout(() => { + const inner = stream as unknown as { + _client: { + _protocol: { exitStatus(id: number, c: number): void }; + }; + outgoing: { id: number }; + }; + inner._client._protocol.exitStatus(inner.outgoing.id, code); + stream.close(); + }, state.exitAfterEofMs); + }; + // Answered when stdin closes, which the client always does — + // with the script for the commands that take one, and empty for + // the rest. Waiting on that rather than on a timer is why this + // cannot answer a question it has not finished reading. + stream.on("end", finish); + }); + }); + }); + }, + ); + + const port = await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + resolve((server.address() as { port: number }).port); + }); + }); + + return { + port, + state, + close: () => + new Promise((resolve) => { + // Closed as well as stopped: a server with a connection still open + // never finishes closing, and the wait lands in afterEach. + for (const client of open) client.end(); + open.clear(); + server.close(() => resolve()); + }), + }; +} diff --git a/src/window_infrastructure/renderer_query_invalidation.test.ts b/src/window_infrastructure/renderer_query_invalidation.test.ts index 5dcbbf5bd6..476ac696d6 100644 --- a/src/window_infrastructure/renderer_query_invalidation.test.ts +++ b/src/window_infrastructure/renderer_query_invalidation.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { queryKeys } from "@/lib/queryKeys"; import { coolifyContracts } from "@/ipc/types/coolify"; import { supabaseContracts } from "@/ipc/types/supabase"; +import { coolifySetupContracts } from "@/ipc/types/coolify_setup"; import { queryInvalidationScopeKey, type WindowSessionId } from "./types"; import { RendererQueryInvalidationConsumer } from "./renderer_query_invalidation"; @@ -202,6 +203,54 @@ describe("Coolify contracts and the window that acted", () => { } }); + it("lets the installer decide when its own window refreshes", () => { + // coolify-setup:run stores a token, which makes every app read as + // connected — so the panel that ran the install would be unmounted by its + // own invalidation, taking with it the screen that says the server ended + // up unencrypted. The panel refreshes coolify itself, when it is ready. + const contract = coolifySetupContracts.run as { + originHandles?: (input: unknown) => Array<{ family: string }>; + invalidates?: (input: unknown) => Array<{ family: string }>; + }; + const claims = (contract.originHandles?.({}) ?? []).map((s) => s.family); + const publishes = (contract.invalidates?.({}) ?? []).map((s) => s.family); + + expect(claims).toContain("coolify"); + // Not apps: nothing repeats that locally, so it must still arrive. + expect(claims).not.toContain("apps"); + expect(publishes).toContain("apps"); + expect(publishes).toContain("coolify"); + + // There is no second publisher to worry about any more: a window that + // wants to see a run in progress asks for the snapshot rather than + // pressing Install again, so every run is published by the one window + // that started it. + }); + + it("tells other windows when an unencrypted token is kept", () => { + // The same write saveToken makes: it is what turns every app connected. + // Without it a second window that watched the install finish goes on + // offering to set a server up, and pressing Install there is refused for + // holding an account it does not know about. + const contract = coolifySetupContracts.acceptInsecureToken as { + originHandles?: (input: unknown) => Array<{ family: string }>; + invalidates?: (input: unknown) => Array<{ family: string }>; + }; + const claims = (contract.originHandles?.(undefined) ?? []).map( + (s) => s.family, + ); + const publishes = (contract.invalidates?.(undefined) ?? []).map( + (s) => s.family, + ); + + expect(publishes).toContain("apps"); + expect(publishes).toContain("coolify"); + // As with run: the finished screen refreshes coolify on its own way out, + // in the order it needs, so it is not handed back mid-write. + expect(claims).toContain("coolify"); + expect(claims).not.toContain("apps"); + }); + it("publishes project creation so other windows see the new project", () => { const { publishes } = handled("createProject", { name: "x" }); expect(publishes).toContain("coolify"); diff --git a/testing/fake-llm-server/coolify.ts b/testing/fake-llm-server/coolify.ts index ba13b75880..7af1976cf4 100644 --- a/testing/fake-llm-server/coolify.ts +++ b/testing/fake-llm-server/coolify.ts @@ -1,3 +1,4 @@ +import { Router } from "express"; import type { Express, Request, Response } from "express"; /** @@ -76,6 +77,12 @@ export function registerFakeCoolify(app: Express): void { const base = "/coolify/api/v1"; + // The same API at the root of the host too: an installed Coolify lives + // there, while a pasted URL points at the /coolify mount. Mounted twice + // rather than rewriting the URL — a rewrite would move every /api/v1 + // request on this shared server, including one another fake meant to answer. + const api = Router(); + // Lets a spec choose the shape of the run before it starts. Named fields // rather than a spread, which would also let a caller replace the Maps. app.post("/coolify/test/reset", (req, res) => { @@ -92,31 +99,31 @@ export function registerFakeCoolify(app: Express): void { res.json([...state.applications.values()]); }); - app.get(`${base}/servers`, (req, res) => { + api.get("/servers", (req, res) => { if (!authed(req, res)) return; res.json(state.servers); }); - app.get(`${base}/projects`, (req, res) => { + api.get("/projects", (req, res) => { if (!authed(req, res)) return; res.json(state.projects); }); - app.post(`${base}/projects`, (req, res) => { + api.post("/projects", (req, res) => { if (!authed(req, res)) return; const project = { uuid: id("prj"), name: String(req.body?.name ?? "") }; state.projects.push(project); res.json(project); }); - app.get(`${base}/security/keys`, (req, res) => { + api.get("/security/keys", (req, res) => { if (!authed(req, res)) return; // The private half is echoed back the way an instance with // read:sensitive would, so a spec can assert what Dyad uploaded. res.json(state.keys); }); - app.post(`${base}/security/keys`, (req, res) => { + api.post("/security/keys", (req, res) => { if (!authed(req, res)) return; const key: FakeKey = { uuid: id("key"), @@ -128,7 +135,7 @@ export function registerFakeCoolify(app: Express): void { res.json({ uuid: key.uuid }); }); - app.post(`${base}/applications/private-deploy-key`, (req, res) => { + api.post("/applications/private-deploy-key", (req, res) => { if (!authed(req, res)) return; const uuid = id("app"); // Resolved, not guessed: the pipeline compares this against the key it @@ -162,7 +169,7 @@ export function registerFakeCoolify(app: Express): void { res.json({ uuid }); }); - app.get(`${base}/applications/:uuid`, (req, res) => { + api.get("/applications/:uuid", (req, res) => { if (!authed(req, res)) return; const found = state.applications.get(req.params.uuid); if (!found) { @@ -172,7 +179,7 @@ export function registerFakeCoolify(app: Express): void { res.json(found); }); - app.patch(`${base}/applications/:uuid`, (req, res) => { + api.patch("/applications/:uuid", (req, res) => { if (!authed(req, res)) return; const found = state.applications.get(req.params.uuid); if (!found) { @@ -183,13 +190,13 @@ export function registerFakeCoolify(app: Express): void { res.json({ uuid: found.uuid }); }); - app.delete(`${base}/applications/:uuid`, (req, res) => { + api.delete("/applications/:uuid", (req, res) => { if (!authed(req, res)) return; state.applications.delete(req.params.uuid); res.json({ ok: true }); }); - app.post(`${base}/applications/:uuid/envs`, (req, res) => { + api.post("/applications/:uuid/envs", (req, res) => { if (!authed(req, res)) return; const found = state.applications.get(req.params.uuid); if (!found) { @@ -201,7 +208,7 @@ export function registerFakeCoolify(app: Express): void { }); // The client falls back to this when POSTing an existing variable 409s. - app.patch(`${base}/applications/:uuid/envs`, (req, res) => { + api.patch("/applications/:uuid/envs", (req, res) => { if (!authed(req, res)) return; const found = state.applications.get(req.params.uuid); if (!found) { @@ -212,7 +219,7 @@ export function registerFakeCoolify(app: Express): void { res.json({ ok: true }); }); - app.post(`${base}/applications/:uuid/start`, (req, res) => { + api.post("/applications/:uuid/start", (req, res) => { if (!authed(req, res)) return; const deploymentUuid = id("dep"); state.deployments.set(deploymentUuid, { @@ -222,7 +229,7 @@ export function registerFakeCoolify(app: Express): void { res.json({ deployment_uuid: deploymentUuid }); }); - app.get(`${base}/deployments/:uuid`, (req, res) => { + api.get("/deployments/:uuid", (req, res) => { if (!authed(req, res)) return; const deployment = state.deployments.get(req.params.uuid); if (!deployment) { @@ -242,4 +249,7 @@ export function registerFakeCoolify(app: Express): void { : JSON.stringify([{ output: "Build finished" }]), }); }); + + app.use(base, api); + app.use("/api/v1", api); } diff --git a/vite.main.config.mts b/vite.main.config.mts index 2b19f32d9a..72725330ab 100644 --- a/vite.main.config.mts +++ b/vite.main.config.mts @@ -39,6 +39,7 @@ export default defineConfig(({ forgeConfigSelf }) => ({ "better-sqlite3", "dyad-keychain-reader", "node-pty", + "ssh2", "mustardscript", "pg", "ws",