Compare commits

...

1 Commits

Author SHA1 Message Date
Mauricio Siu d313ae3ded Add IS_DEMO read-only demo mode
Blocks every non-query tRPC procedure (mutations and subscriptions),
the local/remote terminal websockets, and the unauthenticated deploy
webhooks when IS_DEMO=true. Login still works; sign-up and every
better-auth endpoint except sign-in/get-session/sign-out is blocked
via an allowlist.

Also fixes settings.getUpdateData, which was declared as a mutation
even though it only reads version info — the demo guard caught it
as a false positive, so reclassified it as a query.
2026-09-02 01:55:53 -06:00
11 changed files with 94 additions and 16 deletions
@@ -45,8 +45,8 @@ export const UpdateServer = ({
const [isUpdateAvailable, setIsUpdateAvailable] = useState(
!!updateData?.updateAvailable,
);
const { mutateAsync: getUpdateData, isPending } =
api.settings.getUpdateData.useMutation();
const utils = api.useUtils();
const [isPending, setIsPending] = useState(false);
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
const { data: releaseTag } = api.settings.getReleaseTag.useQuery();
const [latestVersion, setLatestVersion] = useState(
@@ -55,8 +55,9 @@ export const UpdateServer = ({
const [isOpenInternal, setIsOpenInternal] = useState(false);
const handleCheckUpdates = async () => {
setIsPending(true);
try {
const updateData = await getUpdateData();
const updateData = await utils.settings.getUpdateData.fetch();
const versionToUpdate = updateData.latestVersion || "";
setHasCheckedUpdate(true);
setIsUpdateAvailable(updateData.updateAvailable);
@@ -76,6 +77,8 @@ export const UpdateServer = ({
toast.error(
"An error occurred while checking for updates, please try again.",
);
} finally {
setIsPending(false);
}
};
@@ -19,8 +19,7 @@ export const UpdateServerButton = () => {
updateAvailable: false,
});
const { data: isCloud } = api.settings.isCloud.useQuery();
const { mutateAsync: getUpdateData } =
api.settings.getUpdateData.useMutation();
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const checkUpdatesIntervalRef = useRef<null | NodeJS.Timeout>(null);
@@ -48,7 +47,7 @@ export const UpdateServerButton = () => {
return;
}
const fetchedUpdateData = await getUpdateData();
const fetchedUpdateData = await utils.settings.getUpdateData.fetch();
if (fetchedUpdateData?.updateAvailable) {
// Stop interval when update is available
@@ -2,6 +2,7 @@ import {
type Bitbucket,
getBitbucketHeaders,
IS_CLOUD,
IS_DEMO,
shouldDeploy,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
@@ -38,6 +39,10 @@ export default async function handler(
) {
const { refreshToken } = req.query;
try {
if (IS_DEMO) {
res.status(403).json({ message: "This is a read-only demo instance." });
return;
}
if (req.headers["x-github-event"] === "ping") {
res.status(200).json({ message: "Ping received, webhook is active" });
return;
@@ -1,4 +1,4 @@
import { IS_CLOUD, shouldDeploy } from "@dokploy/server";
import { IS_CLOUD, IS_DEMO, shouldDeploy } from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
@@ -21,6 +21,10 @@ export default async function handler(
) {
const { refreshToken } = req.query;
try {
if (IS_DEMO) {
res.status(403).json({ message: "This is a read-only demo instance." });
return;
}
if (req.headers["x-github-event"] === "ping") {
res.status(200).json({ message: "Ping received, webhook is active" });
return;
+6
View File
@@ -6,6 +6,7 @@ import {
findPreviewDeploymentByApplicationId,
findPreviewDeploymentsByPullRequestId,
IS_CLOUD,
IS_DEMO,
removePreviewDeployment,
shouldDeploy,
} from "@dokploy/server";
@@ -30,6 +31,11 @@ export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (IS_DEMO) {
res.status(403).json({ message: "This is a read-only demo instance." });
return;
}
const signature = req.headers["x-hub-signature-256"];
if (!signature) {
res.status(401).json({ message: "Missing signature header" });
+1 -1
View File
@@ -543,7 +543,7 @@ export const settingsRouter = createTRPCRouter({
});
return true;
}),
getUpdateData: protectedProcedure.mutation(async () => {
getUpdateData: protectedProcedure.query(async () => {
if (IS_CLOUD) {
return DEFAULT_UPDATE_DATA;
}
+23 -6
View File
@@ -9,7 +9,7 @@
// import { getServerAuthSession } from "@/server/auth";
import { db } from "@dokploy/server/db";
import { hasValidLicense } from "@dokploy/server/index";
import { hasValidLicense, IS_DEMO } from "@dokploy/server/index";
import type { statements } from "@dokploy/server/lib/access-control";
import { validateRequest } from "@dokploy/server/lib/auth";
import { checkPermission } from "@dokploy/server/services/permission";
@@ -141,6 +141,23 @@ const t = initTRPC
*/
export const createTRPCRouter = t.router;
/**
* Every procedure below is built on this instead of `t.procedure` directly, so
* demo-mode blocking applies everywhere in one place. It checks `type !==
* "query"` rather than `type === "mutation"` because some real deploy/restore
* actions (e.g. deployWithLogs, restoreBackupWithLogs) are implemented as
* `.subscription()` generators, not `.mutation()`.
*/
const baseProcedure = t.procedure.use(({ type, next }) => {
if (IS_DEMO && type !== "query") {
throw new TRPCError({
code: "FORBIDDEN",
message: "This is a read-only demo instance.",
});
}
return next();
});
/**
* Public (unauthenticated) procedure
*
@@ -148,7 +165,7 @@ export const createTRPCRouter = t.router;
* guarantee that a user querying is authorized, but you can still access user session data if they
* are logged in.
*/
export const publicProcedure = t.procedure;
export const publicProcedure = baseProcedure;
/**
* Protected (authenticated) procedure
@@ -158,7 +175,7 @@ export const publicProcedure = t.procedure;
*
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
export const protectedProcedure = baseProcedure.use(({ ctx, next }) => {
if (!ctx.session || !ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
@@ -172,7 +189,7 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
});
});
export const cliProcedure = t.procedure.use(({ ctx, next }) => {
export const cliProcedure = baseProcedure.use(({ ctx, next }) => {
if (
!ctx.session ||
!ctx.user ||
@@ -190,7 +207,7 @@ export const cliProcedure = t.procedure.use(({ ctx, next }) => {
});
});
export const adminProcedure = t.procedure.use(({ ctx, next }) => {
export const adminProcedure = baseProcedure.use(({ ctx, next }) => {
if (
!ctx.session ||
!ctx.user ||
@@ -213,7 +230,7 @@ export const adminProcedure = t.procedure.use(({ ctx, next }) => {
* Does NOT call the license server on every request; full validation (haveValidLicenseKey)
* is used in the UI gate and when activating/validating keys.
*/
export const enterpriseProcedure = t.procedure.use(async ({ ctx, next }) => {
export const enterpriseProcedure = baseProcedure.use(async ({ ctx, next }) => {
if (
!ctx.session ||
!ctx.user ||
@@ -1,5 +1,10 @@
import type http from "node:http";
import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import {
findServerById,
IS_CLOUD,
IS_DEMO,
validateRequest,
} from "@dokploy/server";
import { spawn } from "node-pty";
import { Client } from "ssh2";
import { WebSocketServer } from "ws";
@@ -74,6 +79,13 @@ export const setupDockerContainerTerminalWebSocketServer = (
ws.close(4003, "Not authorized");
return;
}
if (IS_DEMO) {
ws.send("Terminal access is disabled on this read-only demo instance.");
ws.close();
return;
}
try {
if (serverId) {
const server = await findServerById(serverId);
+7
View File
@@ -3,6 +3,7 @@ import {
execAsync,
findServerById,
IS_CLOUD,
IS_DEMO,
validateRequest,
} from "@dokploy/server";
import { publicIpv4, publicIpv6 } from "public-ip";
@@ -107,6 +108,12 @@ export const setupTerminalWebSocketServer = (
return;
}
if (IS_DEMO) {
ws.send("Terminal access is disabled on this read-only demo instance.");
ws.close();
return;
}
let connectionDetails: ConnectConfig = {};
const isLocalServer = serverId === "local";
+5
View File
@@ -4,6 +4,11 @@ import Docker from "dockerode";
export const IS_CLOUD = process.env.IS_CLOUD === "true";
// Public read-only demo instance: every non-query tRPC procedure, the
// terminal/exec websockets, and the deploy webhooks are blocked so visitors
// can browse seeded data without being able to change anything.
export const IS_DEMO = process.env.IS_DEMO === "true";
export const DOKPLOY_DOCKER_API_VERSION =
process.env.DOKPLOY_DOCKER_API_VERSION;
export const DOKPLOY_DOCKER_HOST = process.env.DOKPLOY_DOCKER_HOST;
+21 -1
View File
@@ -9,7 +9,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { APIError, createAuthMiddleware } from "better-auth/api";
import { admin, organization, twoFactor } from "better-auth/plugins";
import { and, desc, eq } from "drizzle-orm";
import { IS_CLOUD } from "../constants";
import { IS_CLOUD, IS_DEMO } from "../constants";
import { db } from "../db";
import * as schema from "../db/schema";
import {
@@ -32,6 +32,20 @@ import { getPublicIpWithFallback } from "../wss/utils";
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
import { betterAuthSecret } from "./auth-secret";
// Default-deny allowlist for demo mode: block every better-auth endpoint
// except the ones a shared read-only login needs (sign-in, session check,
// sign-out). This is deliberately an allowlist, not a blocklist of the
// mutating paths (sign-up, change-password, invite-member, api-key, sso,
// scim, ...) — better-auth plugins add new endpoints over time, and a
// blocklist silently stops covering them.
const DEMO_ALLOWED_AUTH_PATHS = new Set([
"/sign-in/email",
"/get-session",
"/sign-out",
"/ok",
"/error",
]);
const resolveTrustedOrigins = async () => {
try {
if (IS_CLOUD) {
@@ -125,6 +139,12 @@ const createBetterAuth = () =>
...(ctx.context.baseURL ? [new URL(ctx.context.baseURL).origin] : []),
...(await resolveTrustedOrigins()),
].filter(Boolean);
if (IS_DEMO && !DEMO_ALLOWED_AUTH_PATHS.has(ctx.path)) {
throw new APIError("FORBIDDEN", {
message: "This is a read-only demo instance.",
});
}
}),
},
emailVerification: {