Merge branch 'canary' into kucherenko/canary

This commit is contained in:
Mauricio Siu
2025-01-18 18:29:12 -06:00
335 changed files with 20594 additions and 17450 deletions

View File

@@ -28,6 +28,7 @@ import {
updateMariadbById,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
export const mariadbRouter = createTRPCRouter({
create: protectedProcedure
@@ -155,6 +156,31 @@ export const mariadbRouter = createTRPCRouter({
return deployMariadb(input.mariadbId);
}),
deployWithLogs: protectedProcedure
.meta({
openapi: {
path: "/deploy/mariadb-with-logs",
method: "POST",
override: true,
enabled: false,
},
})
.input(apiDeployMariaDB)
.subscription(async ({ input, ctx }) => {
const mariadb = await findMariadbById(input.mariadbId);
if (mariadb.project.adminId !== ctx.user.adminId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this Mariadb",
});
}
return observable<string>((emit) => {
deployMariadb(input.mariadbId, (log) => {
emit.next(log);
});
});
}),
changeStatus: protectedProcedure
.input(apiChangeMariaDBStatus)
.mutation(async ({ input, ctx }) => {

View File

@@ -27,6 +27,7 @@ import {
updateMongoById,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
export const mongoRouter = createTRPCRouter({
create: protectedProcedure
@@ -167,6 +168,31 @@ export const mongoRouter = createTRPCRouter({
}
return deployMongo(input.mongoId);
}),
deployWithLogs: protectedProcedure
.meta({
openapi: {
path: "/deploy/mongo-with-logs",
method: "POST",
override: true,
enabled: false,
},
})
.input(apiDeployMongo)
.subscription(async ({ input, ctx }) => {
const mongo = await findMongoById(input.mongoId);
if (mongo.project.adminId !== ctx.user.adminId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this mongo",
});
}
return observable<string>((emit) => {
deployMongo(input.mongoId, (log) => {
emit.next(log);
});
});
}),
changeStatus: protectedProcedure
.input(apiChangeMongoStatus)
.mutation(async ({ input, ctx }) => {

View File

@@ -29,6 +29,7 @@ import {
stopServiceRemote,
updateMySqlById,
} from "@dokploy/server";
import { observable } from "@trpc/server/observable";
export const mysqlRouter = createTRPCRouter({
create: protectedProcedure
@@ -166,6 +167,31 @@ export const mysqlRouter = createTRPCRouter({
}
return deployMySql(input.mysqlId);
}),
deployWithLogs: protectedProcedure
.meta({
openapi: {
path: "/deploy/mysql-with-logs",
method: "POST",
override: true,
enabled: false,
},
})
.input(apiDeployMySql)
.subscription(async ({ input, ctx }) => {
const mysql = await findMySqlById(input.mysqlId);
if (mysql.project.adminId !== ctx.user.adminId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this MySQL",
});
}
return observable<string>((emit) => {
deployMySql(input.mysqlId, (log) => {
emit.next(log);
});
});
}),
changeStatus: protectedProcedure
.input(apiChangeMySqlStatus)
.mutation(async ({ input, ctx }) => {

View File

@@ -2,20 +2,24 @@ import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from "@/server/api/trpc";
import { db } from "@/server/db";
import {
apiCreateDiscord,
apiCreateEmail,
apiCreateGotify,
apiCreateSlack,
apiCreateTelegram,
apiFindOneNotification,
apiTestDiscordConnection,
apiTestEmailConnection,
apiTestGotifyConnection,
apiTestSlackConnection,
apiTestTelegramConnection,
apiUpdateDiscord,
apiUpdateEmail,
apiUpdateGotify,
apiUpdateSlack,
apiUpdateTelegram,
notifications,
@@ -24,16 +28,19 @@ import {
IS_CLOUD,
createDiscordNotification,
createEmailNotification,
createGotifyNotification,
createSlackNotification,
createTelegramNotification,
findNotificationById,
removeNotificationById,
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendSlackNotification,
sendTelegramNotification,
updateDiscordNotification,
updateEmailNotification,
updateGotifyNotification,
updateSlackNotification,
updateTelegramNotification,
} from "@dokploy/server";
@@ -300,10 +307,61 @@ export const notificationRouter = createTRPCRouter({
telegram: true,
discord: true,
email: true,
gotify: true,
},
orderBy: desc(notifications.createdAt),
...(IS_CLOUD && { where: eq(notifications.adminId, ctx.user.adminId) }),
// TODO: Remove this line when the cloud version is ready
});
}),
createGotify: adminProcedure
.input(apiCreateGotify)
.mutation(async ({ input, ctx }) => {
try {
return await createGotifyNotification(input, ctx.user.adminId);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateGotify: adminProcedure
.input(apiUpdateGotify)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (IS_CLOUD && notification.adminId !== ctx.user.adminId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
return await updateGotifyNotification({
...input,
adminId: ctx.user.adminId,
});
} catch (error) {
throw error;
}
}),
testGotifyConnection: adminProcedure
.input(apiTestGotifyConnection)
.mutation(async ({ input }) => {
try {
await sendGotifyNotification(
input,
"Test Notification",
"Hi, From Dokploy 👋",
);
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error testing the notification",
cause: error,
});
}
}),
});

View File

@@ -1,4 +1,9 @@
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { EventEmitter } from "node:events";
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from "@/server/api/trpc";
import {
apiChangePostgresStatus,
apiCreatePostgres,
@@ -27,6 +32,10 @@ import {
updatePostgresById,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { z } from "zod";
const ee = new EventEmitter();
export const postgresRouter = createTRPCRouter({
create: protectedProcedure
@@ -166,6 +175,32 @@ export const postgresRouter = createTRPCRouter({
}
return deployPostgres(input.postgresId);
}),
deployWithLogs: protectedProcedure
.meta({
openapi: {
path: "/deploy/postgres-with-logs",
method: "POST",
override: true,
enabled: false,
},
})
.input(apiDeployPostgres)
.subscription(async ({ input, ctx }) => {
const postgres = await findPostgresById(input.postgresId);
if (postgres.project.adminId !== ctx.user.adminId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this Postgres",
});
}
return observable<string>((emit) => {
deployPostgres(input.postgresId, (log) => {
emit.next(log);
});
});
}),
changeStatus: protectedProcedure
.input(apiChangePostgresStatus)
.mutation(async ({ input, ctx }) => {

View File

@@ -68,7 +68,7 @@ export const projectRouter = createTRPCRouter({
.input(apiFindOneProject)
.query(async ({ input, ctx }) => {
if (ctx.user.rol === "user") {
const { accesedServices } = await findUserByAuthId(ctx.user.authId);
const { accessedServices } = await findUserByAuthId(ctx.user.authId);
await checkProjectAccess(ctx.user.authId, "access", input.projectId);
@@ -79,28 +79,28 @@ export const projectRouter = createTRPCRouter({
),
with: {
compose: {
where: buildServiceFilter(compose.composeId, accesedServices),
where: buildServiceFilter(compose.composeId, accessedServices),
},
applications: {
where: buildServiceFilter(
applications.applicationId,
accesedServices,
accessedServices,
),
},
mariadb: {
where: buildServiceFilter(mariadb.mariadbId, accesedServices),
where: buildServiceFilter(mariadb.mariadbId, accessedServices),
},
mongo: {
where: buildServiceFilter(mongo.mongoId, accesedServices),
where: buildServiceFilter(mongo.mongoId, accessedServices),
},
mysql: {
where: buildServiceFilter(mysql.mysqlId, accesedServices),
where: buildServiceFilter(mysql.mysqlId, accessedServices),
},
postgres: {
where: buildServiceFilter(postgres.postgresId, accesedServices),
where: buildServiceFilter(postgres.postgresId, accessedServices),
},
redis: {
where: buildServiceFilter(redis.redisId, accesedServices),
where: buildServiceFilter(redis.redisId, accessedServices),
},
},
});
@@ -125,18 +125,18 @@ export const projectRouter = createTRPCRouter({
}),
all: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.rol === "user") {
const { accesedProjects, accesedServices } = await findUserByAuthId(
const { accessedProjects, accessedServices } = await findUserByAuthId(
ctx.user.authId,
);
if (accesedProjects.length === 0) {
if (accessedProjects.length === 0) {
return [];
}
const query = await db.query.projects.findMany({
where: and(
sql`${projects.projectId} IN (${sql.join(
accesedProjects.map((projectId) => sql`${projectId}`),
accessedProjects.map((projectId) => sql`${projectId}`),
sql`, `,
)})`,
eq(projects.adminId, ctx.user.adminId),
@@ -145,27 +145,27 @@ export const projectRouter = createTRPCRouter({
applications: {
where: buildServiceFilter(
applications.applicationId,
accesedServices,
accessedServices,
),
with: { domains: true },
},
mariadb: {
where: buildServiceFilter(mariadb.mariadbId, accesedServices),
where: buildServiceFilter(mariadb.mariadbId, accessedServices),
},
mongo: {
where: buildServiceFilter(mongo.mongoId, accesedServices),
where: buildServiceFilter(mongo.mongoId, accessedServices),
},
mysql: {
where: buildServiceFilter(mysql.mysqlId, accesedServices),
where: buildServiceFilter(mysql.mysqlId, accessedServices),
},
postgres: {
where: buildServiceFilter(postgres.postgresId, accesedServices),
where: buildServiceFilter(postgres.postgresId, accessedServices),
},
redis: {
where: buildServiceFilter(redis.redisId, accesedServices),
where: buildServiceFilter(redis.redisId, accessedServices),
},
compose: {
where: buildServiceFilter(compose.composeId, accesedServices),
where: buildServiceFilter(compose.composeId, accessedServices),
with: { domains: true },
},
},
@@ -239,10 +239,10 @@ export const projectRouter = createTRPCRouter({
}
}),
});
function buildServiceFilter(fieldName: AnyPgColumn, accesedServices: string[]) {
return accesedServices.length > 0
function buildServiceFilter(fieldName: AnyPgColumn, accessedServices: string[]) {
return accessedServices.length > 0
? sql`${fieldName} IN (${sql.join(
accesedServices.map((serviceId) => sql`${serviceId}`),
accessedServices.map((serviceId) => sql`${serviceId}`),
sql`, `,
)})`
: sql`1 = 0`;

View File

@@ -29,6 +29,7 @@ import {
stopServiceRemote,
updateRedisById,
} from "@dokploy/server";
import { observable } from "@trpc/server/observable";
export const redisRouter = createTRPCRouter({
create: protectedProcedure
@@ -189,6 +190,30 @@ export const redisRouter = createTRPCRouter({
}
return deployRedis(input.redisId);
}),
deployWithLogs: protectedProcedure
.meta({
openapi: {
path: "/deploy/redis-with-logs",
method: "POST",
override: true,
enabled: false,
},
})
.input(apiDeployRedis)
.subscription(async ({ input, ctx }) => {
const redis = await findRedisById(input.redisId);
if (redis.project.adminId !== ctx.user.adminId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this Redis",
});
}
return observable<string>((emit) => {
deployRedis(input.redisId, (log) => {
emit.next(log);
});
});
}),
changeStatus: protectedProcedure
.input(apiChangeRedisStatus)
.mutation(async ({ input, ctx }) => {

View File

@@ -96,8 +96,14 @@ export const registryRouter = createTRPCRouter({
return true;
} catch (error) {
console.log("Error Registry:", error);
return false;
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error testing the registry",
cause: error,
});
}
}),
});

View File

@@ -32,6 +32,7 @@ import {
updateServerById,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { and, desc, eq, getTableColumns, isNotNull, sql } from "drizzle-orm";
export const serverRouter = createTRPCRouter({
@@ -126,6 +127,34 @@ export const serverRouter = createTRPCRouter({
throw error;
}
}),
setupWithLogs: protectedProcedure
.meta({
openapi: {
path: "/deploy/server-with-logs",
method: "POST",
override: true,
enabled: false,
},
})
.input(apiFindOneServer)
.subscription(async ({ input, ctx }) => {
try {
const server = await findServerById(input.serverId);
if (server.adminId !== ctx.user.adminId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to setup this server",
});
}
return observable<string>((emit) => {
serverSetup(input.serverId, (log) => {
emit.next(log);
});
});
} catch (error) {
throw error;
}
}),
validate: protectedProcedure
.input(apiFindOneServer)
.query(async ({ input, ctx }) => {

View File

@@ -18,6 +18,7 @@ import next from "next";
import { setupDockerContainerLogsWebSocketServer } from "./wss/docker-container-logs";
import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-container-terminal";
import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats";
import { setupDrawerLogsWebSocketServer } from "./wss/drawer-logs";
import { setupDeploymentLogsWebSocketServer } from "./wss/listen-deployment";
import { setupTerminalWebSocketServer } from "./wss/terminal";
@@ -33,6 +34,7 @@ void app.prepare().then(async () => {
});
// WEBSOCKET
setupDrawerLogsWebSocketServer(server);
setupDeploymentLogsWebSocketServer(server);
setupDockerContainerLogsWebSocketServer(server);
setupDockerContainerTerminalWebSocketServer(server);

View File

@@ -0,0 +1,40 @@
import { execAsync } from "@dokploy/server";
/** Returns if the current operating system is Windows Subsystem for Linux (WSL). */
export const isWSL = async () => {
try {
const { stdout } = await execAsync("uname -r");
const isWSL = stdout.includes("microsoft");
return isWSL;
} catch (error) {
return false;
}
};
/** Returns the Docker host IP address. */
export const getDockerHost = async (): Promise<string> => {
if (process.env.NODE_ENV === "production") {
if (process.platform === "linux" && !(await isWSL())) {
try {
// Try to get the Docker bridge IP first
const { stdout } = await execAsync(
"ip route | awk '/default/ {print $3}'",
);
const hostIp = stdout.trim();
if (!hostIp) {
throw new Error("Failed to get Docker host IP");
}
return hostIp;
} catch (error) {
console.error("Failed to get Docker host IP:", error);
return "172.17.0.1"; // Default Docker bridge network IP
}
}
return "host.docker.internal";
}
return "localhost";
};

View File

@@ -0,0 +1,36 @@
import type http from "node:http";
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws";
import { appRouter } from "../api/root";
import { createTRPCContext } from "../api/trpc";
export const setupDrawerLogsWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
) => {
const wssTerm = new WebSocketServer({
noServer: true,
path: "/drawer-logs",
});
applyWSSHandler({
wss: wssTerm,
router: appRouter,
createContext: createTRPCContext as any,
});
server.on("upgrade", (req, socket, head) => {
const { pathname } = new URL(req.url || "", `http://${req.headers.host}`);
if (pathname === "/_next/webpack-hmr") {
return;
}
if (pathname === "/drawer-logs") {
wssTerm.handleUpgrade(req, socket, head, function done(ws) {
wssTerm.emit("connection", ws, req);
});
}
});
wssTerm.on("connection", async (ws, req) => {
const url = new URL(req.url || "", `http://${req.headers.host}`);
});
};

View File

@@ -1,14 +1,31 @@
import type http from "node:http";
import {
findServerById,
IS_CLOUD,
findServerById,
validateWebSocketRequest,
} from "@dokploy/server";
import { publicIpv4, publicIpv6 } from "public-ip";
import { Client, type ConnectConfig } from "ssh2";
import { WebSocketServer } from "ws";
import { getDockerHost } from "../utils/docker";
import { setupLocalServerSSHKey } from "./utils";
const COMMAND_TO_ALLOW_LOCAL_ACCESS = `
# ----------------------------------------
mkdir -p $HOME/.ssh && \\
chmod 700 $HOME/.ssh && \\
touch $HOME/.ssh/authorized_keys && \\
chmod 600 $HOME/.ssh/authorized_keys && \\
cat /etc/dokploy/ssh/auto_generated-dokploy-local.pub >> $HOME/.ssh/authorized_keys && \\
echo "✓ Dokploy SSH key added successfully. Reopen the terminal in Dokploy to reconnect."
# ----------------------------------------`;
const COMMAND_TO_GRANT_PERMISSION_ACCESS = `
# ----------------------------------------
sudo chown -R $USER:$USER /etc/dokploy/ssh
# ----------------------------------------
`;
export const getPublicIpWithFallback = async () => {
// @ts-ignore
let ip = null;
@@ -73,20 +90,41 @@ export const setupTerminalWebSocketServer = (
return;
}
ws.send("Setting up private SSH key...\n");
const privateKey = await setupLocalServerSSHKey();
try {
ws.send("Setting up private SSH key...\n");
const privateKey = await setupLocalServerSSHKey();
if (!privateKey) {
ws.close();
return;
}
const dockerHost = await getDockerHost();
ws.send(`Found Docker host: ${dockerHost}\n`);
connectionDetails = {
host: dockerHost,
port,
username,
privateKey,
};
} catch (error) {
console.error(`Error setting up private SSH key: ${error}`);
ws.send(`Error setting up private SSH key: ${error}\n`);
if (
error instanceof Error &&
error.message.includes("Permission denied")
) {
ws.send(
`Please run the following command on your server to grant permission access and then reopen this window to reconnect:${COMMAND_TO_GRANT_PERMISSION_ACCESS}`,
);
}
if (!privateKey) {
ws.close();
return;
}
connectionDetails = {
host: "localhost",
port,
username,
privateKey,
};
} else {
const server = await findServerById(serverId);
@@ -161,9 +199,15 @@ export const setupTerminalWebSocketServer = (
})
.on("error", (err) => {
if (err.level === "client-authentication") {
ws.send(
`Authentication failed: Unauthorized ${isLocalServer ? "" : "private SSH key or "}username.\n❌ Error: ${err.message} ${err.level}`,
);
if (isLocalServer) {
ws.send(
`Authentication failed: Please run the command below on your server to allow access. Make sure to run it as the same user as the one configured in connection settings:${COMMAND_TO_ALLOW_LOCAL_ACCESS}\nAfter running the command, reopen this window to reconnect. This procedure is required only once.`,
);
} else {
ws.send(
`Authentication failed: Unauthorized private SSH key or username.\n❌ Error: ${err.message} ${err.level}`,
);
}
} else {
ws.send(`SSH connection error: ${err.message}`);
}

View File

@@ -1,17 +1,7 @@
import { execAsync } from "@dokploy/server";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
const HOME_PATH = process.env.HOME || process.env.USERPROFILE || "/";
const LOCAL_SSH_KEY_PATH = path.join(
HOME_PATH,
".ssh",
"auto_generated-dokploy-local",
);
const AUTHORIZED_KEYS_PATH = path.join(HOME_PATH, ".ssh", "authorized_keys");
import { execAsync, paths } from "@dokploy/server";
export const getShell = () => {
switch (os.platform()) {
@@ -24,39 +14,20 @@ export const getShell = () => {
}
};
/** Returns private SSH key for dokploy local server terminal. Uses already created SSH key or generates a new SSH key, also automatically appends the public key to `authorized_keys`, creating the file if needed. */
/** Returns private SSH key for dokploy local server terminal. Uses already created SSH key or generates a new SSH key.
*/
export const setupLocalServerSSHKey = async () => {
try {
if (!fs.existsSync(LOCAL_SSH_KEY_PATH)) {
// Generate new SSH key if it hasn't been created yet
await execAsync(
`ssh-keygen -t rsa -b 4096 -f ${LOCAL_SSH_KEY_PATH} -N ""`,
);
}
const { SSH_PATH } = paths(true);
const sshKeyPath = path.join(SSH_PATH, "auto_generated-dokploy-local");
const privateKey = fs.readFileSync(LOCAL_SSH_KEY_PATH, "utf8");
const publicKey = fs.readFileSync(`${LOCAL_SSH_KEY_PATH}.pub`, "utf8");
const authKeyContent = `${publicKey}\n`;
if (!fs.existsSync(AUTHORIZED_KEYS_PATH)) {
// Create authorized_keys if it doesn't exist yet
fs.writeFileSync(AUTHORIZED_KEYS_PATH, authKeyContent, { mode: 0o600 });
return privateKey;
}
const existingAuthKeys = fs.readFileSync(AUTHORIZED_KEYS_PATH, "utf8");
if (existingAuthKeys.includes(publicKey)) {
return privateKey;
}
// Append the public key to authorized_keys
fs.appendFileSync(AUTHORIZED_KEYS_PATH, authKeyContent, {
mode: 0o600,
});
return privateKey;
} catch (error) {
console.error("Error getting private SSH key for local terminal:", error);
return "";
if (!fs.existsSync(sshKeyPath)) {
// Generate new SSH key if it hasn't been created yet
await execAsync(
`ssh-keygen -t rsa -b 4096 -f ${sshKeyPath} -N "" -C "dokploy-local-access"`,
);
}
const privateKey = fs.readFileSync(sshKeyPath, "utf8");
return privateKey;
};