purge ssh

This commit is contained in:
Alex Holliday committed 2026-09-04 14:33:13 -07:00
1 parent 074b94b061
commit 71fed33ecd
9 files changed
+13 -104

No files matched your search

+2 -2
View File
@@ -634,7 +634,7 @@
"option": {
"dockerHost": {
"label": "Docker host",
"placeholder": "unix:///var/run/docker.sock or ssh://user@host"
"placeholder": "unix:///var/run/docker.sock"
},
"host": {
"label": "Host",
@@ -716,7 +716,7 @@
"http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.",
"ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.",
"port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.",
"docker": "Enter the Docker host to monitor: a local socket (unix:///var/run/docker.sock or /var/run/docker.sock) or a remote host over SSH (ssh://user@host, with an optional :port). Every container on the host is monitored.",
"docker": "Enter the Docker socket to monitor, as unix:///var/run/docker.sock or an absolute path such as /var/run/docker.sock. Every container on the host is monitored.",
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type. Some games require enabling server queries in their config file — check your game server's documentation. See the <gamedigLink>supported games list</gamedigLink> for protocol details.",
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
+3 -14
View File
@@ -120,22 +120,15 @@ const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ct
}
};
const dockerUrlRegex = /^(?:ssh:\/\/[^@\s]+@[^\s/:@]+(?::\d{1,5})?\/?|unix:\/\/\/\S+|\/\S+)$/;
const dockerUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/;
const refineDockerUrl = (data: { type?: string; url?: string; sshPrivateKey?: string }, ctx: z.RefinementCtx) => {
const refineDockerUrl = (data: { type?: string; url?: string }, ctx: z.RefinementCtx) => {
if (data.type !== "docker" || data.url === undefined) return;
if (!dockerUrlRegex.test(data.url)) {
ctx.addIssue({
code: "custom",
path: ["url"],
message: "Docker host must be ssh://user@host[:port], unix:///path, or an absolute socket path",
});
}
if (data.url.startsWith("ssh://") && !data.sshPrivateKey) {
ctx.addIssue({
code: "custom",
path: ["sshPrivateKey"],
message: "SSH Docker hosts require a private key",
message: "Docker host must be unix:///path or an absolute socket path",
});
}
};
@@ -164,7 +157,6 @@ export const createMonitorBodyValidation = z
tags: z.array(z.string()).optional(),
customUpCodes: z.array(httpStatusCode).default([]),
secret: z.string().optional(),
sshPrivateKey: z.string().optional(),
jsonPath: z.union([z.string(), z.literal("")]).optional(),
expectedValue: z.union([z.string(), z.literal("")]).optional(),
matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(),
@@ -201,7 +193,6 @@ export const editMonitorBodyValidation = z
tags: z.array(z.string()).optional(),
customUpCodes: z.array(httpStatusCode).optional(),
secret: z.string().optional(),
sshPrivateKey: z.string().optional(),
ignoreTlsErrors: z.boolean().optional(),
proxyMode: z.enum(ProxyModes).optional(),
proxyId: proxyIdValidation,
@@ -283,7 +274,6 @@ const importedMonitorSchema = z
tags: z.array(z.string()).default([]),
customUpCodes: z.array(httpStatusCode).default([]),
secret: z.string().optional(),
sshPrivateKey: z.string().optional(),
cpuAlertThreshold: z.number().default(100),
cpuAlertCounter: z.number().default(5),
memoryAlertThreshold: z.number().default(100),
@@ -379,7 +369,6 @@ export const monitorResponseSchema = z
tags: z.array(z.string()),
customUpCodes: z.array(httpStatusCode).optional(),
secret: z.string().optional(),
sshPrivateKey: z.string().optional(),
cpuAlertThreshold: z.number(),
memoryAlertThreshold: z.number(),
diskAlertThreshold: z.number(),
@@ -7,8 +7,8 @@ import type { ILogger } from "@/utils/logger.js";
* The old docker provider interpreted monitor.url as a container name/ID and only
* ever talked to the local socket, so every pre-existing docker monitor's host is
* definitionally unix:///var/run/docker.sock. The container name is preserved in
* the description. Urls already in host form (unix://, ssh://, or an absolute
* socket path) are left untouched, which makes the migration idempotent.
* the description. Urls already in host form (unix:// or an absolute socket
* path) are left untouched, which makes the migration idempotent.
*/
export async function migrateDockerMonitorUrls(logger: ILogger): Promise<void> {
const SERVICE_NAME = "Migration:MigrateDockerMonitorUrls";
@@ -16,7 +16,7 @@ export async function migrateDockerMonitorUrls(logger: ILogger): Promise<void> {
try {
logger.info({ service: SERVICE_NAME, message: "Starting docker monitor url migration" });
const result = await MonitorModel.updateMany({ type: "docker", url: { $not: /^((unix|ssh):\/\/|\/)/ } }, [
const result = await MonitorModel.updateMany({ type: "docker", url: { $not: /^(unix:\/\/|\/)/ } }, [
{
$set: {
description: {
@@ -235,9 +235,6 @@ const MonitorSchema = new Schema<MonitorDocument>(
secret: {
type: String,
},
sshPrivateKey: {
type: String,
},
cpuAlertThreshold: {
type: Number,
default: 100,
@@ -436,7 +436,6 @@ class MongoMonitorsRepository implements IMonitorsRepository {
tags: tagIds,
customUpCodes: doc.customUpCodes ?? [],
secret: doc.secret ?? undefined,
sshPrivateKey: doc.sshPrivateKey ?? undefined,
cpuAlertThreshold: doc.cpuAlertThreshold,
cpuAlertCounter: doc.cpuAlertCounter,
memoryAlertThreshold: doc.memoryAlertThreshold,
@@ -92,7 +92,6 @@ export interface Monitor {
tags: string[];
customUpCodes: HttpStatusCode[];
secret?: string;
sshPrivateKey?: string;
cpuAlertThreshold: number;
cpuAlertCounter: number;
memoryAlertThreshold: number;
+1 -25
View File
@@ -63,31 +63,7 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
return { socketPath };
}
if (url.startsWith("/")) return { socketPath: url };
const match = url.match(/^ssh:\/\/(?:([^@\s]+)@)?([^\s/:@]+)(?::(\d{1,5}))?\/?$/);
if (!match) throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" });
const [, username, host, port] = match;
if (!username)
throw new AppError({
message: "SSH Docker host URL requires a user: ssh://user@host",
status: 422,
service: SERVICE_NAME,
method: "toDockerOptions",
});
if (!monitor.sshPrivateKey)
throw new AppError({
message: "SSH Docker host requires a private key",
status: 422,
service: SERVICE_NAME,
method: "toDockerOptions",
});
return {
protocol: "ssh",
host,
port: port ? Number(port) : 22,
username,
sshOptions: { privateKey: monitor.sshPrivateKey },
};
throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" });
};
private async mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
@@ -9,8 +9,6 @@ import { DOCKER_LOG_TAIL_LINES } from "../../../../src/domain/docker/docker.type
// ── Fixtures ─────────────────────────────────────────────────────────────────
const PRIVATE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----";
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
@@ -116,28 +114,6 @@ describe("DockerProvider", () => {
expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/var/run/docker.sock" });
});
it("parses ssh urls with a private key, defaulting to port 22", async () => {
const { provider, DockerLib } = setup();
await provider.handle(makeMonitor({ url: "ssh://deploy@prod-swarm-01.internal", sshPrivateKey: PRIVATE_KEY }));
expect(DockerLib).toHaveBeenCalledWith({
protocol: "ssh",
host: "prod-swarm-01.internal",
port: 22,
username: "deploy",
sshOptions: { privateKey: PRIVATE_KEY },
});
});
it("parses an explicit ssh port", async () => {
const { provider, DockerLib } = setup();
await provider.handle(makeMonitor({ url: "ssh://deploy@host:2222", sshPrivateKey: PRIVATE_KEY }));
expect(DockerLib).toHaveBeenCalledWith(expect.objectContaining({ port: 2222 }));
});
});
// ── Fail-loudly url validation ───────────────────────────────────────
@@ -151,7 +127,7 @@ describe("DockerProvider", () => {
["a container name (old semantics)", "my-container", "Invalid Docker host URL"],
["tcp engine urls (unsupported)", "tcp://host:2375", "Invalid Docker host URL"],
["https engine urls (unsupported)", "https://host", "Invalid Docker host URL"],
["ssh without a user", "ssh://host", "SSH Docker host URL requires a user: ssh://user@host"],
["ssh engine urls (unsupported)", "ssh://deploy@host", "Invalid Docker host URL"],
];
it.each(invalidUrls)("throws AppError for %s and never constructs a client", async (_label, url, message) => {
@@ -161,15 +137,6 @@ describe("DockerProvider", () => {
expect(DockerLib).not.toHaveBeenCalled();
});
it("throws AppError for an ssh url with no private key", async () => {
const { provider, DockerLib } = setup();
await expect(provider.handle(makeMonitor({ url: "ssh://deploy@host", sshPrivateKey: undefined }))).rejects.toThrow(
"SSH Docker host requires a private key"
);
expect(DockerLib).not.toHaveBeenCalled();
});
it("preserves the toDockerOptions AppError through handle's catch", async () => {
const { provider } = setup();
@@ -608,7 +608,6 @@ describe("monitorValidation — Docker host url", () => {
type: "docker" as const,
url: "unix:///var/run/docker.sock",
};
const PRIVATE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----";
describe("createMonitorBodyValidation", () => {
it("accepts the default unix socket url", () => {
@@ -621,32 +620,15 @@ describe("monitorValidation — Docker host url", () => {
expect(parsed.url).toBe("/run/docker.sock");
});
it("accepts an ssh url with a private key and retains the key", () => {
for (const url of ["ssh://deploy@host", "ssh://deploy@host:2222"]) {
const parsed = createMonitorBodyValidation.parse({ ...baseDockerBody, url, sshPrivateKey: PRIVATE_KEY });
expect(parsed.sshPrivateKey).toBe(PRIVATE_KEY);
}
});
it("rejects container names and unsupported engine urls", () => {
for (const badUrl of ["my-container", "tcp://host:2375", "https://host", "unix://relative/path", ""]) {
for (const badUrl of ["my-container", "tcp://host:2375", "https://host", "ssh://deploy@host", "unix://relative/path", ""]) {
expect(() => createMonitorBodyValidation.parse({ ...baseDockerBody, url: badUrl })).toThrow();
}
});
it("rejects an ssh url without a user", () => {
expect(() => createMonitorBodyValidation.parse({ ...baseDockerBody, url: "ssh://host", sshPrivateKey: PRIVATE_KEY })).toThrow();
});
it("rejects an ssh url without a private key, attributing the issue to sshPrivateKey", () => {
const result = createMonitorBodyValidation.safeParse({ ...baseDockerBody, url: "ssh://deploy@host" });
expect(result.success).toBe(false);
expect(result.error?.issues.some((issue) => issue.path.includes("sshPrivateKey"))).toBe(true);
});
it("does not apply docker url rules to other monitor types", () => {
const parsed = createMonitorBodyValidation.parse({ name: "HTTP check", type: "http", url: "https://example.com" });
expect(parsed.sshPrivateKey).toBeUndefined();
expect(parsed.url).toBe("https://example.com");
});
});
@@ -660,7 +642,7 @@ describe("monitorValidation — Docker host url", () => {
expect(() => editMonitorBodyValidation.parse({ type: "docker", url: "my-container" })).toThrow();
});
it("rejects a docker edit switching to ssh without a key", () => {
it("rejects a docker edit with an ssh url", () => {
expect(() => editMonitorBodyValidation.parse({ type: "docker", url: "ssh://deploy@host" })).toThrow();
});
});