first commit
This commit is contained in:
1 parent
abb09fbea8
commit
72f6d49818
355 files changed
+121239
-3
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,223 @@
|
||||
import "dotenv/config";
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import jwt from "@fastify/jwt";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import websocket from "@fastify/websocket";
|
||||
import prisma from "./lib/prisma";
|
||||
|
||||
import { authRoutes } from "./routes/auth";
|
||||
import { serverRoutes } from "./routes/servers";
|
||||
import { containerRoutes } from "./routes/containers";
|
||||
import { domainRoutes } from "./routes/domains";
|
||||
import { sslRoutes } from "./routes/ssl";
|
||||
import { networkRoutes } from "./routes/networks";
|
||||
import { securityRoutes } from "./routes/security";
|
||||
import { terminalRoutes } from "./routes/terminal";
|
||||
import { logsRoutes } from "./routes/logs";
|
||||
import { metricsRoutes } from "./routes/metrics";
|
||||
import { usersRoutes } from "./routes/users";
|
||||
import { apiKeysRoutes } from "./routes/api-keys";
|
||||
import { appsRoutes } from "./routes/apps";
|
||||
import { backupsRoutes } from "./routes/backups";
|
||||
import { settingsRoutes } from "./routes/settings";
|
||||
import { organizationsRoutes } from "./routes/organizations";
|
||||
import { projectsRoutes } from "./routes/projects";
|
||||
import { gitProviderRoutes } from "./routes/git-providers";
|
||||
import { storageDestinationRoutes } from "./routes/storage-destinations";
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "4000");
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
|
||||
export function getJwtSecretOrThrow(env = process.env): string {
|
||||
const secret = env.JWT_SECRET?.trim();
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
"JWT_SECRET must be configured before starting the backend",
|
||||
);
|
||||
}
|
||||
if (secret.length < 32) {
|
||||
throw new Error("JWT_SECRET must be at least 32 characters long");
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function getRateLimitMax(env = process.env): number {
|
||||
const raw = Number.parseInt(env.RATE_LIMIT_MAX || "300", 10);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 300;
|
||||
}
|
||||
|
||||
function getRateLimitWindow(env = process.env): string {
|
||||
const raw = env.RATE_LIMIT_TIME_WINDOW?.trim();
|
||||
return raw || "1 minute";
|
||||
}
|
||||
|
||||
function getCorsOrigins(env = process.env): string[] {
|
||||
const origins = [env.FRONTEND_URL, ...(env.CORS_ORIGINS || "").split(",")]
|
||||
.map((origin) => origin?.trim())
|
||||
.filter((origin): origin is string => Boolean(origin));
|
||||
|
||||
return origins.length > 0 ? [...new Set(origins)] : ["http://localhost:3000"];
|
||||
}
|
||||
|
||||
function setDefaultSecurityHeaders() {
|
||||
app.addHook("onSend", async (_request, reply, payload) => {
|
||||
reply.header("X-Content-Type-Options", "nosniff");
|
||||
reply.header("X-Frame-Options", "DENY");
|
||||
reply.header("Referrer-Policy", "no-referrer");
|
||||
reply.header(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=()",
|
||||
);
|
||||
reply.header("Cross-Origin-Opener-Policy", "same-origin");
|
||||
reply.header("Cross-Origin-Resource-Policy", "same-origin");
|
||||
reply.header("X-DNS-Prefetch-Control", "off");
|
||||
return payload;
|
||||
});
|
||||
}
|
||||
|
||||
const app = Fastify({
|
||||
trustProxy: process.env.TRUST_PROXY === "true",
|
||||
logger: {
|
||||
level: process.env.NODE_ENV === "production" ? "warn" : "info",
|
||||
transport:
|
||||
process.env.NODE_ENV !== "production"
|
||||
? {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "SYS:HH:MM:ss",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
async function ensureDatabaseConnection() {
|
||||
try {
|
||||
await prisma.$connect();
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
throw new Error(
|
||||
"Database connection failed. Verify DATABASE_URL and database credentials before starting Doktainer.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const jwtSecret = getJwtSecretOrThrow();
|
||||
await ensureDatabaseConnection();
|
||||
setDefaultSecurityHeaders();
|
||||
|
||||
// ── Plugins ──────────────────────────────────────────
|
||||
await app.register(cors, {
|
||||
origin: getCorsOrigins(),
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
});
|
||||
|
||||
await app.register(rateLimit, {
|
||||
global: true,
|
||||
max: getRateLimitMax(),
|
||||
timeWindow: getRateLimitWindow(),
|
||||
addHeaders: {
|
||||
"x-ratelimit-limit": true,
|
||||
"x-ratelimit-remaining": true,
|
||||
"x-ratelimit-reset": true,
|
||||
"retry-after": true,
|
||||
},
|
||||
errorResponseBuilder: (_request, context) => ({
|
||||
success: false,
|
||||
error: `Too many requests, retry in ${context.after}`,
|
||||
code: 429,
|
||||
}),
|
||||
});
|
||||
|
||||
await app.register(jwt, {
|
||||
secret: jwtSecret,
|
||||
sign: { expiresIn: process.env.JWT_EXPIRES_IN || "7d" },
|
||||
});
|
||||
|
||||
await app.register(websocket);
|
||||
|
||||
// ── Health check ──────────────────────────────────────
|
||||
app.get("/health", { config: { rateLimit: false } }, async () => ({
|
||||
status: "ok",
|
||||
uptime: process.uptime(),
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.APP_PUBLIC_VERSION || "unknown",
|
||||
}));
|
||||
|
||||
// ── API Routes ────────────────────────────────────────
|
||||
const API_PREFIX = "/api/v1";
|
||||
|
||||
await app.register(authRoutes, { prefix: `${API_PREFIX}/auth` });
|
||||
await app.register(serverRoutes, { prefix: `${API_PREFIX}/servers` });
|
||||
await app.register(containerRoutes, { prefix: `${API_PREFIX}/containers` });
|
||||
await app.register(domainRoutes, { prefix: `${API_PREFIX}/domains` });
|
||||
await app.register(sslRoutes, { prefix: `${API_PREFIX}/ssl` });
|
||||
await app.register(networkRoutes, { prefix: `${API_PREFIX}/networks` });
|
||||
await app.register(securityRoutes, { prefix: `${API_PREFIX}/security` });
|
||||
await app.register(terminalRoutes, { prefix: `${API_PREFIX}/terminal` });
|
||||
await app.register(logsRoutes, { prefix: `${API_PREFIX}/logs` });
|
||||
await app.register(metricsRoutes, { prefix: `${API_PREFIX}/metrics` });
|
||||
await app.register(usersRoutes, { prefix: `${API_PREFIX}/users` });
|
||||
await app.register(organizationsRoutes, {
|
||||
prefix: `${API_PREFIX}/organizations`,
|
||||
});
|
||||
await app.register(projectsRoutes, { prefix: `${API_PREFIX}/projects` });
|
||||
await app.register(apiKeysRoutes, { prefix: `${API_PREFIX}/api-keys` });
|
||||
await app.register(appsRoutes, { prefix: `${API_PREFIX}/apps` });
|
||||
await app.register(backupsRoutes, { prefix: `${API_PREFIX}/backups` });
|
||||
await app.register(settingsRoutes, { prefix: `${API_PREFIX}/settings` });
|
||||
await app.register(gitProviderRoutes, {
|
||||
prefix: `${API_PREFIX}/git-providers`,
|
||||
});
|
||||
await app.register(storageDestinationRoutes, {
|
||||
prefix: `${API_PREFIX}/storage-destinations`,
|
||||
});
|
||||
|
||||
// ── Error handler ─────────────────────────────────────
|
||||
app.setErrorHandler((error: any, _req, reply) => {
|
||||
app.log.error(error);
|
||||
const code = (error as any).statusCode || 500;
|
||||
reply.status(code).send({
|
||||
success: false,
|
||||
error: (error as any).message || "Internal Server Error",
|
||||
code,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Start ─────────────────────────────────────────────
|
||||
try {
|
||||
await app.listen({ port: PORT, host: HOST });
|
||||
console.log(
|
||||
`\n🚀 Doktainer Server Backend running on http://${HOST}:${PORT}`,
|
||||
);
|
||||
console.log(`📋 Health Check: http://localhost:${PORT}/health\n`);
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code === "EADDRINUSE") {
|
||||
app.log.error(
|
||||
`Port ${PORT} is already in use. Stop the existing process or set a different PORT before running npm run dev.`,
|
||||
);
|
||||
} else if (
|
||||
error.message?.includes("Database connection failed") ||
|
||||
error.name === "PrismaClientInitializationError"
|
||||
) {
|
||||
app.log.error(
|
||||
"Database connection failed during startup. Update .env or bring up the bundled Postgres service before using auth routes.",
|
||||
);
|
||||
} else {
|
||||
app.log.error(err);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void start();
|
||||
}
|
||||
|
||||
export { app, start };
|
||||
@@ -0,0 +1,47 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
|
||||
function getKey(): Buffer {
|
||||
const key = process.env.ENCRYPTION_KEY || 'dev-32-char-encryption-key-here!';
|
||||
// Derive a 32-byte key using SHA-256
|
||||
return crypto.createHash('sha256').update(key).digest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext string (e.g. SSH private key or password)
|
||||
* Returns base64-encoded "iv:authTag:ciphertext"
|
||||
*/
|
||||
export function encrypt(plaintext: string): string {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
|
||||
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return [
|
||||
iv.toString('base64'),
|
||||
authTag.toString('base64'),
|
||||
encrypted.toString('base64'),
|
||||
].join(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an encrypted string produced by `encrypt()`
|
||||
*/
|
||||
export function decrypt(encoded: string): string {
|
||||
const [ivB64, tagB64, dataB64] = encoded.split(':');
|
||||
if (!ivB64 || !tagB64 || !dataB64) throw new Error('Invalid encrypted format');
|
||||
|
||||
const iv = Buffer.from(ivB64, 'base64');
|
||||
const authTag = Buffer.from(tagB64, 'base64');
|
||||
const data = Buffer.from(dataB64, 'base64');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import prisma from "./prisma";
|
||||
|
||||
type DbClient = typeof prisma | Prisma.TransactionClient;
|
||||
|
||||
export interface OrganizationListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logoUrl: string | null;
|
||||
isDefault: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
memberCount: number;
|
||||
serverCount: number;
|
||||
}
|
||||
|
||||
function slugifyOrganizationName(name: string) {
|
||||
const slug = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
return slug || "organization";
|
||||
}
|
||||
|
||||
async function generateUniqueOrganizationSlug(db: DbClient, name: string) {
|
||||
const base = slugifyOrganizationName(name);
|
||||
let slug = base;
|
||||
let counter = 2;
|
||||
|
||||
while (await db.organization.findUnique({ where: { slug } })) {
|
||||
slug = `${base}-${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
export async function listOrganizationsForUser(
|
||||
userId: string,
|
||||
): Promise<OrganizationListItem[]> {
|
||||
const memberships = await prisma.organizationMembership.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ isDefault: "desc" }, { createdAt: "asc" }],
|
||||
select: {
|
||||
isDefault: true,
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
logoUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
servers: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return memberships.map((membership) => ({
|
||||
id: membership.organization.id,
|
||||
name: membership.organization.name,
|
||||
slug: membership.organization.slug,
|
||||
logoUrl: membership.organization.logoUrl,
|
||||
isDefault: membership.isDefault,
|
||||
createdAt: membership.organization.createdAt,
|
||||
updatedAt: membership.organization.updatedAt,
|
||||
memberCount: membership.organization._count.members,
|
||||
serverCount: membership.organization._count.servers,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createOrganizationForUser(
|
||||
db: DbClient,
|
||||
input: {
|
||||
userId: string;
|
||||
name: string;
|
||||
logoUrl?: string | null;
|
||||
createdById?: string;
|
||||
makeDefault?: boolean;
|
||||
},
|
||||
) {
|
||||
const name = input.name.trim() || "My Organization";
|
||||
const slug = await generateUniqueOrganizationSlug(db, name);
|
||||
|
||||
if (input.makeDefault ?? true) {
|
||||
await db.organizationMembership.updateMany({
|
||||
where: { userId: input.userId },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
}
|
||||
|
||||
const organization = await db.organization.create({
|
||||
data: {
|
||||
name,
|
||||
slug,
|
||||
logoUrl: input.logoUrl ?? null,
|
||||
createdById: input.createdById ?? input.userId,
|
||||
},
|
||||
});
|
||||
|
||||
await db.organizationMembership.create({
|
||||
data: {
|
||||
organizationId: organization.id,
|
||||
userId: input.userId,
|
||||
isDefault: input.makeDefault ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
if (input.makeDefault ?? true) {
|
||||
await db.user.update({
|
||||
where: { id: input.userId },
|
||||
data: { activeOrganizationId: organization.id },
|
||||
});
|
||||
}
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
export async function setDefaultOrganizationForUser(
|
||||
db: DbClient,
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
) {
|
||||
await db.organizationMembership.updateMany({
|
||||
where: { userId },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
|
||||
await db.organizationMembership.update({
|
||||
where: {
|
||||
organizationId_userId: {
|
||||
organizationId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
data: { isDefault: true },
|
||||
});
|
||||
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: { activeOrganizationId: organizationId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function userHasOrganizationAccess(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
) {
|
||||
const membership = await prisma.organizationMembership.findUnique({
|
||||
where: {
|
||||
organizationId_userId: {
|
||||
organizationId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
select: { organizationId: true },
|
||||
});
|
||||
|
||||
return Boolean(membership);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ||
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
|
||||
export default prisma;
|
||||
@@ -0,0 +1,150 @@
|
||||
const DEFAULT_PUBLIC_APP_ORIGIN = "http://localhost:3000";
|
||||
|
||||
type HeaderMap = Record<string, string | string[] | undefined>;
|
||||
type HeaderSource =
|
||||
| HeaderMap
|
||||
| {
|
||||
get(name: string): string | null;
|
||||
};
|
||||
|
||||
function normalizeBaseUrl(value: string | null | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function isBindableHost(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\[|\]$/g, "");
|
||||
return normalized === "0.0.0.0" || normalized === "::";
|
||||
}
|
||||
|
||||
function isUsablePublicOrigin(value: string | null | undefined) {
|
||||
const normalized = normalizeBaseUrl(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
if (isBindableHost(parsed.hostname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function readHeader(source: HeaderSource | undefined, name: string) {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ("get" in source && typeof source.get === "function") {
|
||||
return source.get(name)?.trim() || null;
|
||||
}
|
||||
|
||||
const headers = source as HeaderMap;
|
||||
const value = headers[name] ?? headers[name.toLowerCase()];
|
||||
if (Array.isArray(value)) {
|
||||
return value[0]?.trim() || null;
|
||||
}
|
||||
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function firstForwardedValue(value: string | null) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.split(",")[0]?.trim() || null;
|
||||
}
|
||||
|
||||
function getBrowserOrigin() {
|
||||
const browserWindow = (
|
||||
globalThis as {
|
||||
window?: { location?: { origin?: string } };
|
||||
}
|
||||
).window;
|
||||
|
||||
if (!browserWindow?.location?.origin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return browserWindow.location.origin;
|
||||
}
|
||||
|
||||
export function resolvePublicAppOrigin(
|
||||
options: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
headers?: HeaderSource;
|
||||
protocol?: string | null;
|
||||
browserOrigin?: string | null;
|
||||
fallbackOrigin?: string;
|
||||
} = {},
|
||||
) {
|
||||
const env = options.env ?? process.env;
|
||||
const configuredOrigin = isUsablePublicOrigin(
|
||||
env.NEXT_PUBLIC_PANEL_URL ?? env.FRONTEND_URL,
|
||||
);
|
||||
if (configuredOrigin) {
|
||||
return configuredOrigin;
|
||||
}
|
||||
|
||||
const host = firstForwardedValue(
|
||||
readHeader(options.headers, "x-forwarded-host") ||
|
||||
readHeader(options.headers, "host"),
|
||||
);
|
||||
const protocol =
|
||||
firstForwardedValue(readHeader(options.headers, "x-forwarded-proto")) ||
|
||||
normalizeBaseUrl(options.protocol)?.replace(/:$/, "") ||
|
||||
null;
|
||||
|
||||
if (host) {
|
||||
return `${protocol || "http"}://${host}`;
|
||||
}
|
||||
|
||||
const browserOrigin = normalizeBaseUrl(
|
||||
options.browserOrigin ?? getBrowserOrigin(),
|
||||
);
|
||||
if (browserOrigin) {
|
||||
return browserOrigin;
|
||||
}
|
||||
|
||||
return normalizeBaseUrl(options.fallbackOrigin) || DEFAULT_PUBLIC_APP_ORIGIN;
|
||||
}
|
||||
|
||||
export function resolvePublicAppUrl(
|
||||
path: string,
|
||||
options?: Parameters<typeof resolvePublicAppOrigin>[0],
|
||||
) {
|
||||
return new URL(path, `${resolvePublicAppOrigin(options)}/`).toString();
|
||||
}
|
||||
|
||||
export function isPublicHttpsUrl(value: string) {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const isLocalHost =
|
||||
host === "localhost" ||
|
||||
host === "127.0.0.1" ||
|
||||
host === "0.0.0.0" ||
|
||||
host.endsWith(".local");
|
||||
|
||||
return parsed.protocol === "https:" && !isLocalHost;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import crypto from "crypto";
|
||||
import { isIP } from "node:net";
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
userId?: string;
|
||||
userRole?: string;
|
||||
organizationId?: string;
|
||||
apiKeyId?: string;
|
||||
apiKeyPermissions?: string[];
|
||||
authMethod?: "jwt" | "api-key";
|
||||
}
|
||||
}
|
||||
|
||||
function getHeaderValue(value: string | string[] | undefined): string | null {
|
||||
if (typeof value === "string") {
|
||||
return value.trim() || null;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value[0]?.trim() || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractApiKey(req: FastifyRequest): string | null {
|
||||
const headerKey = getHeaderValue(req.headers["x-api-key"]);
|
||||
if (headerKey) return headerKey;
|
||||
|
||||
const authorization = getHeaderValue(req.headers.authorization);
|
||||
if (!authorization?.toLowerCase().startsWith("bearer ")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = authorization.slice(7).trim();
|
||||
return token.startsWith("vpk_") ? token : null;
|
||||
}
|
||||
|
||||
function extractOrganizationId(req: FastifyRequest): string | null {
|
||||
return getHeaderValue(req.headers["x-organization-id"]);
|
||||
}
|
||||
|
||||
export const API_KEY_PERMISSIONS = [
|
||||
"read:servers",
|
||||
"write:servers",
|
||||
"read:containers",
|
||||
"write:containers",
|
||||
"read:logs",
|
||||
"read:metrics",
|
||||
"read:domains",
|
||||
"write:domains",
|
||||
"read:security",
|
||||
"write:security",
|
||||
"write:backups",
|
||||
] as const;
|
||||
|
||||
export type ApiKeyPermission = (typeof API_KEY_PERMISSIONS)[number];
|
||||
|
||||
const API_KEY_PERMISSION_SET = new Set<string>(API_KEY_PERMISSIONS);
|
||||
const IMPLIED_API_KEY_PERMISSIONS: Partial<
|
||||
Record<ApiKeyPermission, ApiKeyPermission[]>
|
||||
> = {
|
||||
"write:servers": ["read:servers"],
|
||||
"write:containers": ["read:containers"],
|
||||
"write:domains": ["read:domains"],
|
||||
"write:security": ["read:security"],
|
||||
};
|
||||
|
||||
type UserSecuritySettings = {
|
||||
sessionTimeoutMinutes: number;
|
||||
ipWhitelistEnabled: boolean;
|
||||
ipWhitelist: string[];
|
||||
} | null;
|
||||
|
||||
export function isValidApiKeyPermission(
|
||||
value: string,
|
||||
): value is ApiKeyPermission {
|
||||
return API_KEY_PERMISSION_SET.has(value.trim());
|
||||
}
|
||||
|
||||
export function normalizeApiKeyPermissions(
|
||||
permissions: string[] | null | undefined,
|
||||
): ApiKeyPermission[] {
|
||||
if (!permissions?.length) return [];
|
||||
|
||||
const normalized = new Set<ApiKeyPermission>();
|
||||
for (const permission of permissions) {
|
||||
const candidate = permission.trim();
|
||||
if (isValidApiKeyPermission(candidate)) {
|
||||
normalized.add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return [...normalized];
|
||||
}
|
||||
|
||||
function normalizeClientIp(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
|
||||
if (trimmed.startsWith("[")) {
|
||||
const end = trimmed.indexOf("]");
|
||||
if (end > 0) {
|
||||
return normalizeClientIp(trimmed.slice(1, end));
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("::ffff:")) {
|
||||
const ipv4Mapped = trimmed.slice(7);
|
||||
if (isIP(ipv4Mapped) === 4) {
|
||||
return ipv4Mapped;
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\d+\.\d+\.\d+\.\d+:\d+$/.test(trimmed)) {
|
||||
return trimmed.slice(0, trimmed.lastIndexOf(":"));
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function ipv4ToInt(ip: string): number {
|
||||
return (
|
||||
ip
|
||||
.split(".")
|
||||
.map((segment) => Number.parseInt(segment, 10))
|
||||
.reduce((accumulator, octet) => (accumulator << 8) + octet, 0) >>> 0
|
||||
);
|
||||
}
|
||||
|
||||
function matchesIpv4Cidr(clientIp: string, entry: string): boolean {
|
||||
const [baseIp, maskText] = entry.split("/");
|
||||
const maskSize = Number.parseInt(maskText, 10);
|
||||
if (isIP(baseIp) !== 4 || isIP(clientIp) !== 4) {
|
||||
return false;
|
||||
}
|
||||
if (!Number.isInteger(maskSize) || maskSize < 0 || maskSize > 32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mask = maskSize === 0 ? 0 : (0xffffffff << (32 - maskSize)) >>> 0;
|
||||
return (ipv4ToInt(baseIp) & mask) === (ipv4ToInt(clientIp) & mask);
|
||||
}
|
||||
|
||||
export function getClientIp(
|
||||
req: Pick<FastifyRequest, "headers" | "ip">,
|
||||
): string {
|
||||
const forwarded =
|
||||
process.env.TRUST_PROXY === "true"
|
||||
? getHeaderValue(req.headers["x-forwarded-for"])
|
||||
: null;
|
||||
if (forwarded) {
|
||||
const firstHop = forwarded.split(",")[0];
|
||||
return normalizeClientIp(firstHop);
|
||||
}
|
||||
|
||||
return normalizeClientIp(req.ip ?? "");
|
||||
}
|
||||
|
||||
export function ipMatchesWhitelist(
|
||||
clientIp: string,
|
||||
whitelist: string[],
|
||||
): boolean {
|
||||
const normalizedClientIp = normalizeClientIp(clientIp);
|
||||
if (!normalizedClientIp || !whitelist.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const entry of whitelist) {
|
||||
const candidate = entry.trim();
|
||||
if (!candidate) continue;
|
||||
|
||||
if (candidate.includes("/")) {
|
||||
if (matchesIpv4Cidr(normalizedClientIp, candidate)) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizeClientIp(candidate) === normalizedClientIp) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function requestHasPermission(
|
||||
grantedPermissions: string[] | undefined,
|
||||
requiredPermission: ApiKeyPermission,
|
||||
): boolean {
|
||||
if (!grantedPermissions?.length) return false;
|
||||
|
||||
if (grantedPermissions.includes(requiredPermission)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return grantedPermissions.some((grantedPermission) => {
|
||||
if (!isValidApiKeyPermission(grantedPermission)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return IMPLIED_API_KEY_PERMISSIONS[grantedPermission]?.includes(
|
||||
requiredPermission,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function enforceApiKeyPermissions(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
requiredPermissions: ApiKeyPermission[],
|
||||
): boolean {
|
||||
if (req.authMethod !== "api-key") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
requiredPermissions.some((permission) =>
|
||||
requestHasPermission(req.apiKeyPermissions, permission),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
reply.status(403).send({
|
||||
success: false,
|
||||
error: `Forbidden — API key requires one of: ${requiredPermissions.join(", ")}`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function permissionRequiresWriteAccess(permission: string): boolean {
|
||||
return permission.startsWith("write:");
|
||||
}
|
||||
|
||||
export function enforceUserRolePermissions(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
requiredPermissions: ApiKeyPermission[],
|
||||
): boolean {
|
||||
if (req.authMethod === "api-key") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!requiredPermissions.some(permissionRequiresWriteAccess)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userRank = ROLE_RANK[req.userRole || "VIEWER"] ?? 0;
|
||||
const minimumWriteRank = ROLE_RANK.DEVELOPER;
|
||||
|
||||
if (userRank >= minimumWriteRank) {
|
||||
return true;
|
||||
}
|
||||
|
||||
reply.status(403).send({
|
||||
success: false,
|
||||
error: "Forbidden — viewer role is read-only",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
export function requireApiKeyPermission(
|
||||
...requiredPermissions: ApiKeyPermission[]
|
||||
) {
|
||||
return async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
await authenticate(req, reply);
|
||||
if (reply.sent) return;
|
||||
|
||||
if (!enforceUserRolePermissions(req, reply, requiredPermissions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
enforceApiKeyPermissions(req, reply, requiredPermissions);
|
||||
};
|
||||
}
|
||||
|
||||
function enforceUserSecurityPolicies(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
settings: UserSecuritySettings,
|
||||
tokenIssuedAt?: number,
|
||||
): boolean {
|
||||
if (!settings) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (settings.ipWhitelistEnabled && settings.ipWhitelist.length > 0) {
|
||||
const clientIp = getClientIp(req);
|
||||
if (!ipMatchesWhitelist(clientIp, settings.ipWhitelist)) {
|
||||
reply.status(403).send({
|
||||
success: false,
|
||||
error: "Forbidden — your IP address is not in the allowed list",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof tokenIssuedAt === "number" &&
|
||||
Number.isFinite(tokenIssuedAt) &&
|
||||
settings.sessionTimeoutMinutes > 0
|
||||
) {
|
||||
const maxSessionAgeMs = settings.sessionTimeoutMinutes * 60 * 1000;
|
||||
if (Date.now() - tokenIssuedAt * 1000 > maxSessionAgeMs) {
|
||||
reply.status(401).send({
|
||||
success: false,
|
||||
error: "Unauthorized — session expired",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function authenticateApiKey(
|
||||
rawKey: string,
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { keyHash },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
settings: {
|
||||
select: {
|
||||
sessionTimeoutMinutes: true,
|
||||
ipWhitelistEnabled: true,
|
||||
ipWhitelist: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiKey || !apiKey.user.isActive || !apiKey.isActive) {
|
||||
reply.status(401).send({
|
||||
success: false,
|
||||
error: "Unauthorized — invalid or revoked API key",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (apiKey.expiresAt && apiKey.expiresAt.getTime() <= Date.now()) {
|
||||
reply.status(401).send({
|
||||
success: false,
|
||||
error: "Unauthorized — API key expired",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.apiKey.update({
|
||||
where: { id: apiKey.id },
|
||||
data: {
|
||||
lastUsed: new Date(),
|
||||
requestCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
req.userId = apiKey.user.id;
|
||||
req.userRole = apiKey.user.role;
|
||||
req.organizationId = apiKey.organizationId;
|
||||
req.apiKeyId = apiKey.id;
|
||||
req.apiKeyPermissions = normalizeApiKeyPermissions(apiKey.permissions);
|
||||
req.authMethod = "api-key";
|
||||
|
||||
enforceUserSecurityPolicies(req, reply, apiKey.user.settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: verify JWT token from Authorization header
|
||||
* Attaches userId and userRole to request
|
||||
*/
|
||||
export async function authenticate(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const query = req.query as { token?: string } | undefined;
|
||||
const queryToken = query?.token;
|
||||
|
||||
if (queryToken && !req.headers.authorization) {
|
||||
req.headers.authorization = `Bearer ${queryToken}`;
|
||||
}
|
||||
|
||||
const apiKey = extractApiKey(req);
|
||||
if (apiKey) {
|
||||
await authenticateApiKey(apiKey, req, reply);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await req.jwtVerify<{
|
||||
sub: string;
|
||||
role: string;
|
||||
iat?: number;
|
||||
}>();
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
activeOrganizationId: true,
|
||||
organizationMemberships: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
isDefault: true,
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
select: {
|
||||
sessionTimeoutMinutes: true,
|
||||
ipWhitelistEnabled: true,
|
||||
ipWhitelist: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
reply.status(401).send({
|
||||
success: false,
|
||||
error: "Unauthorized — invalid or expired token",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const availableOrganizationIds = user.organizationMemberships.map(
|
||||
(membership) => membership.organizationId,
|
||||
);
|
||||
const requestedOrganizationId = extractOrganizationId(req);
|
||||
|
||||
if (
|
||||
requestedOrganizationId &&
|
||||
!availableOrganizationIds.includes(requestedOrganizationId)
|
||||
) {
|
||||
reply.status(403).send({
|
||||
success: false,
|
||||
error: "Forbidden — you do not have access to that organization",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackOrganizationId =
|
||||
(user.activeOrganizationId &&
|
||||
availableOrganizationIds.includes(user.activeOrganizationId)
|
||||
? user.activeOrganizationId
|
||||
: null) ??
|
||||
user.organizationMemberships.find((membership) => membership.isDefault)
|
||||
?.organizationId ??
|
||||
availableOrganizationIds[0];
|
||||
|
||||
req.userId = user.id;
|
||||
req.userRole = user.role;
|
||||
req.organizationId = requestedOrganizationId ?? fallbackOrganizationId;
|
||||
req.authMethod = "jwt";
|
||||
|
||||
if (!enforceUserSecurityPolicies(req, reply, user.settings, payload.iat)) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
reply.status(401).send({
|
||||
success: false,
|
||||
error: "Unauthorized — invalid or expired token",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a minimum role. Role hierarchy: SUPER_ADMIN > OPERATOR > DEVELOPER > VIEWER
|
||||
*/
|
||||
const ROLE_RANK: Record<string, number> = {
|
||||
VIEWER: 0,
|
||||
DEVELOPER: 1,
|
||||
OPERATOR: 2,
|
||||
SUPER_ADMIN: 3,
|
||||
};
|
||||
|
||||
export function requireRole(minRole: string) {
|
||||
return async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
await authenticate(req, reply);
|
||||
if (reply.sent) return; // auth already rejected
|
||||
|
||||
const userRank = ROLE_RANK[req.userRole || "VIEWER"] ?? 0;
|
||||
const minRank = ROLE_RANK[minRole] ?? 99;
|
||||
|
||||
if (userRank < minRank) {
|
||||
reply.status(403).send({
|
||||
success: false,
|
||||
error: `Forbidden — requires at least ${minRole} role`,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
for Backend/server-only layer
|
||||
@@ -0,0 +1,144 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import crypto from "crypto";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma";
|
||||
import {
|
||||
API_KEY_PERMISSIONS,
|
||||
isValidApiKeyPermission,
|
||||
normalizeApiKeyPermissions,
|
||||
requireRole,
|
||||
} from "../middleware/auth";
|
||||
import { auditLog } from "../services/audit.service";
|
||||
|
||||
function generateApiKey(): { raw: string; prefix: string; hash: string } {
|
||||
const raw = "vpk_" + crypto.randomBytes(24).toString("hex");
|
||||
const prefix = raw.slice(0, 12) + "_";
|
||||
const hash = crypto.createHash("sha256").update(raw).digest("hex");
|
||||
return { raw, prefix, hash };
|
||||
}
|
||||
|
||||
export async function apiKeysRoutes(app: FastifyInstance) {
|
||||
// GET /api-keys
|
||||
app.get(
|
||||
"/",
|
||||
{ preHandler: [requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const keys = await prisma.apiKey.findMany({
|
||||
where: { userId: req.userId!, organizationId: req.organizationId! },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
keyPrefix: true,
|
||||
permissions: true,
|
||||
lastUsed: true,
|
||||
expiresAt: true,
|
||||
isActive: true,
|
||||
requestCount: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return reply.send({ success: true, data: keys });
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api-keys
|
||||
app.post(
|
||||
"/",
|
||||
{ preHandler: [requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const body = z
|
||||
.object({
|
||||
name: z.string().min(1).max(64),
|
||||
permissions: z.array(z.string()).default([]),
|
||||
expiresIn: z.enum(["never", "30d", "90d", "1y"]).default("never"),
|
||||
})
|
||||
.safeParse(req.body);
|
||||
if (!body.success)
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
|
||||
const invalidPermissions = body.data.permissions.filter(
|
||||
(permission) => !isValidApiKeyPermission(permission),
|
||||
);
|
||||
if (invalidPermissions.length > 0) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: `Invalid API key permissions: ${invalidPermissions.join(", ")}`,
|
||||
allowedPermissions: API_KEY_PERMISSIONS,
|
||||
});
|
||||
}
|
||||
|
||||
const permissions = normalizeApiKeyPermissions(body.data.permissions);
|
||||
|
||||
const { raw, prefix, hash } = generateApiKey();
|
||||
|
||||
const expiresAt =
|
||||
body.data.expiresIn === "never"
|
||||
? null
|
||||
: body.data.expiresIn === "30d"
|
||||
? new Date(Date.now() + 30 * 86400000)
|
||||
: body.data.expiresIn === "90d"
|
||||
? new Date(Date.now() + 90 * 86400000)
|
||||
: new Date(Date.now() + 365 * 86400000);
|
||||
|
||||
const key = await prisma.apiKey.create({
|
||||
data: {
|
||||
name: body.data.name,
|
||||
organizationId: req.organizationId!,
|
||||
keyHash: hash,
|
||||
keyPrefix: prefix,
|
||||
permissions,
|
||||
userId: req.userId!,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
action: "API_KEY_CREATE",
|
||||
category: "AUTH",
|
||||
level: "INFO",
|
||||
message: `API key "${key.name}" created`,
|
||||
});
|
||||
|
||||
// Return the raw key ONCE — never stored in plaintext
|
||||
return reply.status(201).send({
|
||||
success: true,
|
||||
data: { ...key, rawKey: raw },
|
||||
message: "Save this key now — it will NOT be shown again",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api-keys/:id — revoke
|
||||
app.delete(
|
||||
"/:id",
|
||||
{ preHandler: [requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const key = await prisma.apiKey.findFirst({
|
||||
where: { id, userId: req.userId!, organizationId: req.organizationId! },
|
||||
});
|
||||
if (!key)
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "API key not found" });
|
||||
|
||||
await prisma.apiKey.update({ where: { id }, data: { isActive: false } });
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
action: "API_KEY_REVOKE",
|
||||
category: "AUTH",
|
||||
level: "WARNING",
|
||||
message: `API key "${key.name}" revoked`,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, message: "API key revoked" });
|
||||
},
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,841 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import bcrypt from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma";
|
||||
import { auditLog } from "../services/audit.service";
|
||||
import { authenticate } from "../middleware/auth";
|
||||
import { dispatchRuntimeNotification } from "../services/notification.service";
|
||||
import {
|
||||
createTwoFactorSetup,
|
||||
revealManualEntryKey,
|
||||
verifyEncryptedTwoFactorToken,
|
||||
} from "../services/two-factor.service";
|
||||
import { createOrganizationForUser } from "../lib/organizations";
|
||||
|
||||
const LoginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
totpCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{6}$/)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const RegisterSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
name: z.string().min(2),
|
||||
});
|
||||
|
||||
const InvitationAcceptSchema = z.object({
|
||||
name: z.string().min(2).optional(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
const TwoFactorEnableSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{6}$/),
|
||||
});
|
||||
|
||||
const TwoFactorDisableSchema = z.object({
|
||||
currentPassword: z.string().min(8),
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{6}$/),
|
||||
});
|
||||
|
||||
const authBodyLimit = 16 * 1024;
|
||||
const authRateLimit = {
|
||||
max: 5,
|
||||
timeWindow: "1 minute",
|
||||
};
|
||||
|
||||
const registerRateLimit = {
|
||||
max: 3,
|
||||
timeWindow: "15 minutes",
|
||||
};
|
||||
|
||||
async function ensureUserSettings(userId: string) {
|
||||
return prisma.userSettings.upsert({
|
||||
where: { userId },
|
||||
update: {},
|
||||
create: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
function buildAuthResponse(
|
||||
app: FastifyInstance,
|
||||
user: {
|
||||
id: string;
|
||||
role: string;
|
||||
name: string;
|
||||
email: string;
|
||||
activeOrganizationId?: string | null;
|
||||
},
|
||||
) {
|
||||
const token = app.jwt.sign({
|
||||
sub: user.id,
|
||||
role: user.role,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
activeOrganizationId: user.activeOrganizationId ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getPublicRegistrationStatus() {
|
||||
const userCount = await prisma.user.count();
|
||||
|
||||
return {
|
||||
registrationOpen: userCount === 0,
|
||||
hasOwnerAccount: userCount > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
app.get("/registration-status", async (_req, reply) => {
|
||||
const status = await getPublicRegistrationStatus();
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: status,
|
||||
});
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/login",
|
||||
{ bodyLimit: authBodyLimit, config: { rateLimit: authRateLimit } },
|
||||
async (req, reply) => {
|
||||
const body = LoginSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const { email, password, totpCode } = body.data;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
include: {
|
||||
settings: {
|
||||
select: {
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecretEnc: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
if (user?.id) {
|
||||
await auditLog({
|
||||
userId: user.id,
|
||||
organizationId: user.activeOrganizationId ?? undefined,
|
||||
action: "LOGIN_FAILED",
|
||||
category: "AUTH",
|
||||
level: "WARNING",
|
||||
message: `Failed login attempt for \"${user.email}\"`,
|
||||
meta: { reason: user.isActive ? "unknown" : "inactive_user" },
|
||||
});
|
||||
}
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ success: false, error: "Invalid credentials" });
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
await auditLog({
|
||||
userId: user.id,
|
||||
organizationId: user.activeOrganizationId ?? undefined,
|
||||
action: "LOGIN_FAILED",
|
||||
category: "AUTH",
|
||||
level: "WARNING",
|
||||
message: `Failed login attempt for \"${user.email}\"`,
|
||||
meta: { reason: "invalid_password" },
|
||||
});
|
||||
|
||||
if (user.activeOrganizationId) {
|
||||
await dispatchRuntimeNotification({
|
||||
organizationId: user.activeOrganizationId,
|
||||
action: "security_breach",
|
||||
title: `Security alert for ${user.email}`,
|
||||
message: `A failed login attempt was detected for ${user.email}.`,
|
||||
resourceType: "user",
|
||||
resourceId: user.id,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
reason: "invalid_password",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ success: false, error: "Invalid credentials" });
|
||||
}
|
||||
|
||||
const twoFactorEnabled = Boolean(
|
||||
user.settings?.twoFactorEnabled && user.settings.twoFactorSecretEnc,
|
||||
);
|
||||
|
||||
if (twoFactorEnabled && !totpCode) {
|
||||
return reply.send({
|
||||
success: true,
|
||||
requiresTwoFactor: true,
|
||||
message: "Two-factor authentication code is required",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
twoFactorEnabled &&
|
||||
user.settings?.twoFactorSecretEnc &&
|
||||
!(await verifyEncryptedTwoFactorToken(
|
||||
user.settings.twoFactorSecretEnc,
|
||||
totpCode!,
|
||||
))
|
||||
) {
|
||||
await auditLog({
|
||||
userId: user.id,
|
||||
organizationId: user.activeOrganizationId ?? undefined,
|
||||
action: "LOGIN_2FA_FAILED",
|
||||
category: "AUTH",
|
||||
level: "WARNING",
|
||||
message: `Invalid two-factor code for \"${user.email}\"`,
|
||||
meta: { reason: "invalid_totp" },
|
||||
});
|
||||
|
||||
if (user.activeOrganizationId) {
|
||||
await dispatchRuntimeNotification({
|
||||
organizationId: user.activeOrganizationId,
|
||||
action: "security_breach",
|
||||
title: `Security alert for ${user.email}`,
|
||||
message: `An invalid two-factor authentication code was submitted for ${user.email}.`,
|
||||
resourceType: "user",
|
||||
resourceId: `${user.id}:2fa`,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
reason: "invalid_totp",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ success: false, error: "Invalid authentication code" });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLogin: new Date() },
|
||||
});
|
||||
|
||||
const auth = buildAuthResponse(app, user);
|
||||
|
||||
await auditLog({
|
||||
userId: user.id,
|
||||
action: "LOGIN",
|
||||
category: "AUTH",
|
||||
level: "INFO",
|
||||
message: `User "${user.email}" logged in`,
|
||||
meta: { twoFactorEnabled },
|
||||
});
|
||||
|
||||
return reply.send({ success: true, ...auth });
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/register",
|
||||
{ bodyLimit: authBodyLimit, config: { rateLimit: registerRateLimit } },
|
||||
async (req, reply) => {
|
||||
const body = RegisterSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const { email, password, name } = body.data;
|
||||
const status = await getPublicRegistrationStatus();
|
||||
|
||||
if (!status.registrationOpen) {
|
||||
return reply.status(403).send({
|
||||
success: false,
|
||||
error: "Public registration is closed",
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
return reply
|
||||
.status(409)
|
||||
.send({ success: false, error: "Email already registered" });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
const createdUser = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
name,
|
||||
passwordHash,
|
||||
role: "SUPER_ADMIN" as never,
|
||||
allServersAccess: true,
|
||||
},
|
||||
});
|
||||
|
||||
const organization = await createOrganizationForUser(tx, {
|
||||
userId: createdUser.id,
|
||||
createdById: createdUser.id,
|
||||
name: "My Organization",
|
||||
makeDefault: true,
|
||||
});
|
||||
|
||||
return tx.user.update({
|
||||
where: { id: createdUser.id },
|
||||
data: { activeOrganizationId: organization.id },
|
||||
});
|
||||
});
|
||||
|
||||
const auth = buildAuthResponse(app, user);
|
||||
|
||||
await auditLog({
|
||||
userId: user.id,
|
||||
action: "REGISTER",
|
||||
category: "AUTH",
|
||||
level: "INFO",
|
||||
message: `New user registered: "${email}"`,
|
||||
});
|
||||
|
||||
return reply.status(201).send({ success: true, ...auth });
|
||||
},
|
||||
);
|
||||
|
||||
app.get("/invitations/:token", async (req, reply) => {
|
||||
const { token } = req.params as { token: string };
|
||||
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
|
||||
|
||||
const invitation = await prisma.userInvitation.findUnique({
|
||||
where: { tokenHash },
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
allServersAccess: true,
|
||||
serverIds: true,
|
||||
expiresAt: true,
|
||||
acceptedAt: true,
|
||||
revokedAt: true,
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
logoUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Invitation not found" });
|
||||
}
|
||||
|
||||
if (invitation.revokedAt) {
|
||||
return reply
|
||||
.status(410)
|
||||
.send({ success: false, error: "Invitation has been revoked" });
|
||||
}
|
||||
|
||||
if (invitation.acceptedAt) {
|
||||
return reply
|
||||
.status(410)
|
||||
.send({ success: false, error: "Invitation has already been used" });
|
||||
}
|
||||
|
||||
if (invitation.expiresAt.getTime() < Date.now()) {
|
||||
return reply
|
||||
.status(410)
|
||||
.send({ success: false, error: "Invitation has expired" });
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
id: invitation.id,
|
||||
email: invitation.email,
|
||||
name: invitation.name,
|
||||
role: invitation.role,
|
||||
allServersAccess: invitation.allServersAccess,
|
||||
serverIds: invitation.serverIds,
|
||||
expiresAt: invitation.expiresAt,
|
||||
organization: invitation.organization,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/invitations/:token/accept",
|
||||
{ bodyLimit: authBodyLimit, config: { rateLimit: registerRateLimit } },
|
||||
async (req, reply) => {
|
||||
const { token } = req.params as { token: string };
|
||||
const body = InvitationAcceptSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
|
||||
const invitation = await prisma.userInvitation.findUnique({
|
||||
where: { tokenHash },
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
allServersAccess: true,
|
||||
serverIds: true,
|
||||
expiresAt: true,
|
||||
acceptedAt: true,
|
||||
revokedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Invitation not found" });
|
||||
}
|
||||
if (invitation.revokedAt) {
|
||||
return reply
|
||||
.status(410)
|
||||
.send({ success: false, error: "Invitation has been revoked" });
|
||||
}
|
||||
if (invitation.acceptedAt) {
|
||||
return reply
|
||||
.status(410)
|
||||
.send({ success: false, error: "Invitation has already been used" });
|
||||
}
|
||||
if (invitation.expiresAt.getTime() < Date.now()) {
|
||||
return reply
|
||||
.status(410)
|
||||
.send({ success: false, error: "Invitation has expired" });
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { email: invitation.email },
|
||||
});
|
||||
if (existing) {
|
||||
return reply
|
||||
.status(409)
|
||||
.send({ success: false, error: "Email already registered" });
|
||||
}
|
||||
|
||||
const uniqueServerIds = [...new Set(invitation.serverIds)];
|
||||
const existingServers =
|
||||
uniqueServerIds.length > 0
|
||||
? await prisma.server.findMany({
|
||||
where: {
|
||||
id: { in: uniqueServerIds },
|
||||
organizationId: invitation.organizationId,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: [];
|
||||
const validServerIds = existingServers.map((server) => server.id);
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12);
|
||||
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
const createdUser = await tx.user.create({
|
||||
data: {
|
||||
email: invitation.email,
|
||||
name: body.data.name ?? invitation.name,
|
||||
passwordHash,
|
||||
role: invitation.role,
|
||||
activeOrganizationId: invitation.organizationId,
|
||||
allServersAccess:
|
||||
invitation.role === "SUPER_ADMIN"
|
||||
? true
|
||||
: invitation.allServersAccess,
|
||||
organizationMemberships: {
|
||||
create: {
|
||||
organizationId: invitation.organizationId,
|
||||
isDefault: true,
|
||||
},
|
||||
},
|
||||
serverAssignments:
|
||||
invitation.role === "SUPER_ADMIN" ||
|
||||
invitation.allServersAccess ||
|
||||
validServerIds.length === 0
|
||||
? undefined
|
||||
: {
|
||||
create: validServerIds.map((serverId) => ({ serverId })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userInvitation.update({
|
||||
where: { id: invitation.id },
|
||||
data: { acceptedAt: new Date() },
|
||||
});
|
||||
|
||||
return createdUser;
|
||||
});
|
||||
|
||||
const auth = buildAuthResponse(app, user);
|
||||
|
||||
await auditLog({
|
||||
userId: user.id,
|
||||
organizationId: invitation.organizationId,
|
||||
action: "INVITATION_ACCEPT",
|
||||
category: "AUTH",
|
||||
level: "SUCCESS",
|
||||
message: `Invitation accepted for "${user.email}"`,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, ...auth });
|
||||
},
|
||||
);
|
||||
|
||||
app.get("/me", { preHandler: [authenticate] }, async (req, reply) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.userId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
activeOrganizationId: true,
|
||||
lastLogin: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "User not found" });
|
||||
}
|
||||
return reply.send({ success: true, user });
|
||||
});
|
||||
|
||||
app.post("/logout", { preHandler: [authenticate] }, async (req, reply) => {
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
action: "LOGOUT",
|
||||
category: "AUTH",
|
||||
level: "INFO",
|
||||
message: "User logged out",
|
||||
});
|
||||
return reply.send({ success: true, message: "Logged out successfully" });
|
||||
});
|
||||
|
||||
app.patch(
|
||||
"/password",
|
||||
{
|
||||
preHandler: [authenticate],
|
||||
bodyLimit: authBodyLimit,
|
||||
config: { rateLimit: authRateLimit },
|
||||
},
|
||||
async (req, reply) => {
|
||||
const body = z
|
||||
.object({ currentPassword: z.string(), newPassword: z.string().min(8) })
|
||||
.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: req.userId! } });
|
||||
if (!user) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "User not found" });
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(
|
||||
body.data.currentPassword,
|
||||
user.passwordHash,
|
||||
);
|
||||
if (!valid) {
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ success: false, error: "Current password is incorrect" });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.newPassword, 12);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash },
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: user.id,
|
||||
action: "PASSWORD_CHANGE",
|
||||
category: "AUTH",
|
||||
level: "INFO",
|
||||
message: "Password changed",
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Password updated successfully",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/2fa/setup",
|
||||
{
|
||||
preHandler: [authenticate],
|
||||
bodyLimit: authBodyLimit,
|
||||
config: { rateLimit: authRateLimit },
|
||||
},
|
||||
async (req, reply) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.userId! },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
settings: {
|
||||
select: {
|
||||
panelName: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorPendingSecretEnc: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "User not found" });
|
||||
}
|
||||
|
||||
if (user.settings?.twoFactorEnabled) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Two-factor authentication is already enabled",
|
||||
});
|
||||
}
|
||||
|
||||
const setup = await createTwoFactorSetup({
|
||||
accountEmail: user.email,
|
||||
issuer:
|
||||
user.settings?.panelName ||
|
||||
`${process.env.NEXT_PUBLIC_PANEL_NAME ?? "DOKTAINER"}`,
|
||||
});
|
||||
|
||||
await prisma.userSettings.upsert({
|
||||
where: { userId: user.id },
|
||||
update: {
|
||||
twoFactorPendingSecretEnc: setup.encryptedSecret,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
twoFactorPendingSecretEnc: setup.encryptedSecret,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: user.id,
|
||||
action: "TWO_FACTOR_SETUP_STARTED",
|
||||
category: "AUTH",
|
||||
level: "INFO",
|
||||
message: "Two-factor authentication setup started",
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
qrCodeDataUrl: setup.qrCodeDataUrl,
|
||||
otpauthUrl: setup.otpauthUrl,
|
||||
manualEntryKey: setup.secret,
|
||||
},
|
||||
message: "Scan the QR code and confirm with a 6-digit code",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/2fa/enable",
|
||||
{
|
||||
preHandler: [authenticate],
|
||||
bodyLimit: authBodyLimit,
|
||||
config: { rateLimit: authRateLimit },
|
||||
},
|
||||
async (req, reply) => {
|
||||
const body = TwoFactorEnableSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const settings = await ensureUserSettings(req.userId!);
|
||||
if (!settings.twoFactorPendingSecretEnc) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: "Start 2FA setup first" });
|
||||
}
|
||||
|
||||
if (
|
||||
!(await verifyEncryptedTwoFactorToken(
|
||||
settings.twoFactorPendingSecretEnc,
|
||||
body.data.code,
|
||||
))
|
||||
) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: "Invalid authentication code" });
|
||||
}
|
||||
|
||||
await prisma.userSettings.update({
|
||||
where: { userId: req.userId! },
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecretEnc: settings.twoFactorPendingSecretEnc,
|
||||
twoFactorPendingSecretEnc: null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
action: "TWO_FACTOR_ENABLED",
|
||||
category: "AUTH",
|
||||
level: "SUCCESS",
|
||||
message: "Two-factor authentication enabled",
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Two-factor authentication enabled successfully",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/2fa",
|
||||
{
|
||||
preHandler: [authenticate],
|
||||
bodyLimit: authBodyLimit,
|
||||
config: { rateLimit: authRateLimit },
|
||||
},
|
||||
async (req, reply) => {
|
||||
const body = TwoFactorDisableSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.userId! },
|
||||
include: {
|
||||
settings: {
|
||||
select: {
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecretEnc: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "User not found" });
|
||||
}
|
||||
|
||||
if (
|
||||
!user.settings?.twoFactorEnabled ||
|
||||
!user.settings.twoFactorSecretEnc
|
||||
) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Two-factor authentication is not enabled",
|
||||
});
|
||||
}
|
||||
|
||||
const passwordValid = await bcrypt.compare(
|
||||
body.data.currentPassword,
|
||||
user.passwordHash,
|
||||
);
|
||||
if (!passwordValid) {
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ success: false, error: "Current password is incorrect" });
|
||||
}
|
||||
|
||||
if (
|
||||
!(await verifyEncryptedTwoFactorToken(
|
||||
user.settings.twoFactorSecretEnc,
|
||||
body.data.code,
|
||||
))
|
||||
) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: "Invalid authentication code" });
|
||||
}
|
||||
|
||||
await prisma.userSettings.update({
|
||||
where: { userId: req.userId! },
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecretEnc: null,
|
||||
twoFactorPendingSecretEnc: null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
action: "TWO_FACTOR_DISABLED",
|
||||
category: "AUTH",
|
||||
level: "WARNING",
|
||||
message: "Two-factor authentication disabled",
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Two-factor authentication disabled successfully",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.get("/2fa/status", { preHandler: [authenticate] }, async (req, reply) => {
|
||||
const settings = await ensureUserSettings(req.userId!);
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
enabled: settings.twoFactorEnabled,
|
||||
pendingSetup: Boolean(settings.twoFactorPendingSecretEnc),
|
||||
manualEntryKey: settings.twoFactorPendingSecretEnc
|
||||
? revealManualEntryKey(settings.twoFactorPendingSecretEnc)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { authenticate, requireApiKeyPermission } from "../middleware/auth";
|
||||
import { getLogs } from "../services/audit.service";
|
||||
import { LogCategory, LogLevel } from "@prisma/client";
|
||||
|
||||
export async function logsRoutes(app: FastifyInstance) {
|
||||
// GET /logs — paginated audit logs
|
||||
app.get(
|
||||
"/",
|
||||
{ preHandler: [authenticate, requireApiKeyPermission("read:logs")] },
|
||||
async (req, reply) => {
|
||||
const {
|
||||
serverId,
|
||||
category,
|
||||
level,
|
||||
search,
|
||||
limit = "100",
|
||||
offset = "0",
|
||||
} = req.query as Record<string, string>;
|
||||
|
||||
const result = await getLogs({
|
||||
organizationId: req.organizationId,
|
||||
serverId,
|
||||
category: category as LogCategory | undefined,
|
||||
level: level as LogLevel | undefined,
|
||||
search,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: result.logs,
|
||||
total: result.total,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import prisma from "../lib/prisma";
|
||||
import { authenticate, requireApiKeyPermission } from "../middleware/auth";
|
||||
|
||||
const metricsAccess = [authenticate, requireApiKeyPermission("read:metrics")];
|
||||
|
||||
export async function metricsRoutes(app: FastifyInstance) {
|
||||
// GET /metrics/overview — dashboard-level aggregated stats
|
||||
app.get("/overview", { preHandler: metricsAccess }, async (req, reply) => {
|
||||
const organizationId = req.organizationId;
|
||||
const [
|
||||
serverCount,
|
||||
containerCount,
|
||||
domainCount,
|
||||
onlineCount,
|
||||
runningContainers,
|
||||
sslCount,
|
||||
recentLogs,
|
||||
] = await Promise.all([
|
||||
prisma.server.count({ where: { organizationId } }),
|
||||
prisma.container.count({
|
||||
where: { server: { organizationId, status: "ONLINE" } },
|
||||
}),
|
||||
prisma.domain.count({ where: { organizationId } }),
|
||||
prisma.server.count({ where: { organizationId, status: "ONLINE" } }),
|
||||
prisma.container.count({
|
||||
where: {
|
||||
status: "RUNNING",
|
||||
server: { organizationId, status: "ONLINE" },
|
||||
},
|
||||
}),
|
||||
prisma.sslCert.count({
|
||||
where: { status: "VALID", domain: { organizationId } },
|
||||
}),
|
||||
prisma.auditLog.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
include: {
|
||||
user: { select: { name: true } },
|
||||
server: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
servers: { total: serverCount, online: onlineCount },
|
||||
containers: { total: containerCount, running: runningContainers },
|
||||
domains: { total: domainCount },
|
||||
ssl: { valid: sslCount },
|
||||
recentLogs,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// GET /metrics/server/:serverId/history — last N metric snapshots
|
||||
app.get(
|
||||
"/server/:serverId/history",
|
||||
{ preHandler: metricsAccess },
|
||||
async (req, reply) => {
|
||||
const { serverId } = req.params as { serverId: string };
|
||||
const { limit = "24" } = req.query as { limit?: string };
|
||||
|
||||
const server = await prisma.server.findFirst({
|
||||
where: { id: serverId, organizationId: req.organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!server) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
}
|
||||
|
||||
const metrics = await prisma.serverMetric.findMany({
|
||||
where: { serverId },
|
||||
orderBy: { recordedAt: "desc" },
|
||||
take: parseInt(limit),
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: metrics.reverse().map((m) => ({
|
||||
...m,
|
||||
ramUsed: m.ramUsed.toString(),
|
||||
ramTotal: m.ramTotal.toString(),
|
||||
diskUsed: m.diskUsed.toString(),
|
||||
diskTotal: m.diskTotal.toString(),
|
||||
uptimeSec: m.uptimeSec.toString(),
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// GET /metrics/live — aggregated chart data for dashboard widgets
|
||||
app.get("/live", { preHandler: metricsAccess }, async (req, reply) => {
|
||||
const { points = "12" } = req.query as { points?: string };
|
||||
const limit = Math.min(Math.max(parseInt(points, 10) || 12, 3), 24);
|
||||
|
||||
const servers = await prisma.server.findMany({
|
||||
where: { organizationId: req.organizationId },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
const onlineServerIds = servers.map((server) => server.id);
|
||||
|
||||
const [recentMetrics, containerGroups] =
|
||||
onlineServerIds.length > 0
|
||||
? await Promise.all([
|
||||
prisma.serverMetric.findMany({
|
||||
where: {
|
||||
serverId: { in: onlineServerIds },
|
||||
},
|
||||
orderBy: { recordedAt: "desc" },
|
||||
take: limit * Math.max(onlineServerIds.length, 1),
|
||||
select: {
|
||||
cpuPct: true,
|
||||
ramPct: true,
|
||||
diskPct: true,
|
||||
recordedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.container.groupBy({
|
||||
by: ["serverId", "status"],
|
||||
where: {
|
||||
serverId: { in: onlineServerIds },
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
])
|
||||
: [[], []];
|
||||
|
||||
const resourceBucketMap = new Map<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
cpuTotal: number;
|
||||
ramTotal: number;
|
||||
diskTotal: number;
|
||||
count: number;
|
||||
lastRecordedAt: Date;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const metric of recentMetrics) {
|
||||
const bucketDate = new Date(metric.recordedAt);
|
||||
bucketDate.setSeconds(0, 0);
|
||||
const key = bucketDate.toISOString();
|
||||
const existing = resourceBucketMap.get(key);
|
||||
|
||||
if (existing) {
|
||||
existing.cpuTotal += metric.cpuPct;
|
||||
existing.ramTotal += metric.ramPct;
|
||||
existing.diskTotal += metric.diskPct;
|
||||
existing.count += 1;
|
||||
if (metric.recordedAt > existing.lastRecordedAt) {
|
||||
existing.lastRecordedAt = metric.recordedAt;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
resourceBucketMap.set(key, {
|
||||
label: bucketDate.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
cpuTotal: metric.cpuPct,
|
||||
ramTotal: metric.ramPct,
|
||||
diskTotal: metric.diskPct,
|
||||
count: 1,
|
||||
lastRecordedAt: metric.recordedAt,
|
||||
});
|
||||
}
|
||||
|
||||
const resourceUsage = Array.from(resourceBucketMap.entries())
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.slice(-limit)
|
||||
.map(([, bucket]) => ({
|
||||
time: bucket.label,
|
||||
cpu: Number((bucket.cpuTotal / bucket.count).toFixed(1)),
|
||||
ram: Number((bucket.ramTotal / bucket.count).toFixed(1)),
|
||||
disk: Number((bucket.diskTotal / bucket.count).toFixed(1)),
|
||||
recordedAt: bucket.lastRecordedAt.toISOString(),
|
||||
}));
|
||||
|
||||
const serverMap = new Map(
|
||||
servers.map((server) => [
|
||||
server.id,
|
||||
{
|
||||
name: server.name,
|
||||
running: 0,
|
||||
stopped: 0,
|
||||
other: 0,
|
||||
total: 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
for (const group of containerGroups) {
|
||||
const server = serverMap.get(group.serverId);
|
||||
if (!server) continue;
|
||||
|
||||
const count = group._count._all;
|
||||
server.total += count;
|
||||
|
||||
if (group.status === "RUNNING") {
|
||||
server.running += count;
|
||||
} else if (group.status === "STOPPED") {
|
||||
server.stopped += count;
|
||||
} else {
|
||||
server.other += count;
|
||||
}
|
||||
}
|
||||
|
||||
const containerStatusByServer = Array.from(serverMap.values()).map(
|
||||
(server) => ({
|
||||
name: server.name,
|
||||
running: server.running,
|
||||
stopped: server.stopped,
|
||||
other: server.other,
|
||||
total: server.total,
|
||||
}),
|
||||
);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
resourceUsage,
|
||||
containerStatusByServer,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma";
|
||||
import { authenticate, requireRole } from "../middleware/auth";
|
||||
import { auditLog } from "../services/audit.service";
|
||||
import * as ssh from "../services/ssh.service";
|
||||
|
||||
const NetworkSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
driver: z.string().min(1).max(40).default("bridge"),
|
||||
scope: z.string().min(1).max(40).default("local"),
|
||||
subnet: z.string().max(80).optional(),
|
||||
gateway: z.string().max(80).optional(),
|
||||
containers: z.number().int().min(0).default(0),
|
||||
serverId: z.string().min(1),
|
||||
});
|
||||
|
||||
export async function networkRoutes(app: FastifyInstance) {
|
||||
app.get("/", { preHandler: [authenticate] }, async (req, reply) => {
|
||||
const { serverId } = req.query as { serverId?: string };
|
||||
|
||||
const networks = await prisma.network.findMany({
|
||||
where: {
|
||||
...(serverId ? { serverId } : {}),
|
||||
server: { organizationId: req.organizationId! },
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
include: {
|
||||
server: {
|
||||
select: { id: true, name: true, ip: true, organizationId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return reply.send({ success: true, data: networks });
|
||||
});
|
||||
|
||||
app.get("/:id", { preHandler: [authenticate] }, async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const network = await prisma.network.findUnique({
|
||||
where: { id },
|
||||
include: { server: true },
|
||||
});
|
||||
|
||||
if (!network || network.server.organizationId !== req.organizationId) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Network not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await ssh.dockerNetworkInspect(
|
||||
network.server,
|
||||
network.name,
|
||||
);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
network: {
|
||||
id: network.id,
|
||||
name: network.name,
|
||||
driver: network.driver,
|
||||
scope: network.scope,
|
||||
subnet: network.subnet,
|
||||
gateway: network.gateway,
|
||||
containers: network.containers,
|
||||
serverId: network.serverId,
|
||||
createdAt: network.createdAt,
|
||||
updatedAt: network.updatedAt,
|
||||
},
|
||||
server: {
|
||||
id: network.server.id,
|
||||
name: network.server.name,
|
||||
ip: network.server.ip,
|
||||
},
|
||||
detail,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load network details",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/",
|
||||
{ preHandler: [requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const body = NetworkSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const server = await prisma.server.findUnique({
|
||||
where: { id: body.data.serverId },
|
||||
});
|
||||
|
||||
if (!server || server.organizationId !== req.organizationId) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
}
|
||||
|
||||
const existing = await prisma.network.findFirst({
|
||||
where: { serverId: body.data.serverId, name: body.data.name },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return reply.status(409).send({
|
||||
success: false,
|
||||
error: "Network already exists on this server",
|
||||
});
|
||||
}
|
||||
|
||||
const dockerStatus = await ssh.getDockerRuntimeStatus(server);
|
||||
if (!dockerStatus.available) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error:
|
||||
dockerStatus.reason ||
|
||||
"Docker is not available on the selected server",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await ssh.createDockerNetwork(server, {
|
||||
name: body.data.name,
|
||||
driver: body.data.driver,
|
||||
subnet: body.data.subnet,
|
||||
gateway: body.data.gateway,
|
||||
});
|
||||
} catch (error) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? `Failed to create Docker network on ${server.name}: ${error.message}`
|
||||
: `Failed to create Docker network on ${server.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
const network = await prisma.network.create({
|
||||
data: {
|
||||
...body.data,
|
||||
subnet: body.data.subnet?.trim() || null,
|
||||
gateway: body.data.gateway?.trim() || null,
|
||||
},
|
||||
include: {
|
||||
server: {
|
||||
select: { id: true, name: true, ip: true, organizationId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: server.id,
|
||||
action: "NETWORK_ADD",
|
||||
category: "SYSTEM",
|
||||
level: "SUCCESS",
|
||||
message: `Network "${network.name}" created on server "${server.name}"`,
|
||||
});
|
||||
|
||||
return reply.status(201).send({ success: true, data: network });
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/sync",
|
||||
{ preHandler: [requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const body = z
|
||||
.object({ serverId: z.string().optional() })
|
||||
.safeParse(req.body ?? {});
|
||||
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const servers = await prisma.server.findMany({
|
||||
where: {
|
||||
...(body.data.serverId ? { id: body.data.serverId } : {}),
|
||||
organizationId: req.organizationId!,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
if (servers.length === 0) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
}
|
||||
|
||||
const summary: Array<{
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
synced: number;
|
||||
}> = [];
|
||||
|
||||
for (const server of servers) {
|
||||
let actualNetworks;
|
||||
try {
|
||||
actualNetworks = await ssh.listDockerNetworks(server);
|
||||
} catch (error) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? `Failed to sync Docker networks from ${server.name}: ${error.message}`
|
||||
: `Failed to sync Docker networks from ${server.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
const networkNames = actualNetworks.map((network) => network.name);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const network of actualNetworks) {
|
||||
await tx.network.upsert({
|
||||
where: {
|
||||
serverId_name: {
|
||||
serverId: server.id,
|
||||
name: network.name,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
driver: network.driver,
|
||||
scope: network.scope,
|
||||
subnet: network.subnet ?? null,
|
||||
gateway: network.gateway ?? null,
|
||||
containers: network.containers,
|
||||
},
|
||||
create: {
|
||||
serverId: server.id,
|
||||
name: network.name,
|
||||
driver: network.driver,
|
||||
scope: network.scope,
|
||||
subnet: network.subnet ?? null,
|
||||
gateway: network.gateway ?? null,
|
||||
containers: network.containers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.network.deleteMany({
|
||||
where: {
|
||||
serverId: server.id,
|
||||
...(networkNames.length > 0
|
||||
? { name: { notIn: networkNames } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: server.id,
|
||||
action: "NETWORK_SYNC",
|
||||
category: "SYSTEM",
|
||||
level: "INFO",
|
||||
message: `Synced ${actualNetworks.length} Docker networks from server "${server.name}"`,
|
||||
});
|
||||
|
||||
summary.push({
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
synced: actualNetworks.length,
|
||||
});
|
||||
}
|
||||
|
||||
const networks = await prisma.network.findMany({
|
||||
where: {
|
||||
...(body.data.serverId ? { serverId: body.data.serverId } : {}),
|
||||
server: { organizationId: req.organizationId! },
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
include: {
|
||||
server: {
|
||||
select: { id: true, name: true, ip: true, organizationId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return reply.send({ success: true, data: networks, meta: { summary } });
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/:id",
|
||||
{ preHandler: [requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const network = await prisma.network.findUnique({
|
||||
where: { id },
|
||||
include: { server: true },
|
||||
});
|
||||
|
||||
if (!network || network.server.organizationId !== req.organizationId) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Network not found" });
|
||||
}
|
||||
|
||||
const dockerStatus = await ssh.getDockerRuntimeStatus(network.server);
|
||||
if (!dockerStatus.available) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error:
|
||||
dockerStatus.reason ||
|
||||
"Docker is not available on the selected server",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await ssh.removeDockerNetwork(network.server, network.name);
|
||||
} catch (error) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? `Failed to delete Docker network from ${network.server.name}: ${error.message}`
|
||||
: `Failed to delete Docker network from ${network.server.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.network.delete({ where: { id } });
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: network.serverId,
|
||||
action: "NETWORK_DELETE",
|
||||
category: "SYSTEM",
|
||||
level: "WARNING",
|
||||
message: `Network "${network.name}" deleted from server "${network.server.name}"`,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, message: "Network deleted" });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma";
|
||||
import {
|
||||
createOrganizationForUser,
|
||||
listOrganizationsForUser,
|
||||
setDefaultOrganizationForUser,
|
||||
} from "../lib/organizations";
|
||||
import { authenticate, requireRole } from "../middleware/auth";
|
||||
import { auditLog } from "../services/audit.service";
|
||||
import * as ssh from "../services/ssh.service";
|
||||
|
||||
const OrganizationCreateSchema = z.object({
|
||||
name: z.string().trim().min(2).max(80),
|
||||
logoUrl: z.string().trim().url().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
const OrganizationUpdateSchema = z.object({
|
||||
name: z.string().trim().min(2).max(80).optional(),
|
||||
logoUrl: z.string().trim().url().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
const OrganizationDeleteSchema = z.object({
|
||||
confirmation: z.literal("DELETE"),
|
||||
});
|
||||
|
||||
export async function organizationsRoutes(app: FastifyInstance) {
|
||||
app.get("/", { preHandler: [authenticate] }, async (req, reply) => {
|
||||
const organizations = await listOrganizationsForUser(req.userId!);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: organizations.map((organization) => ({
|
||||
...organization,
|
||||
isActive: organization.id === req.organizationId,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/",
|
||||
{ preHandler: [requireRole("SUPER_ADMIN")] },
|
||||
async (req, reply) => {
|
||||
const body = OrganizationCreateSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const organization = await prisma.$transaction(async (tx) => {
|
||||
const currentMembershipCount = await tx.organizationMembership.count({
|
||||
where: { userId: req.userId! },
|
||||
});
|
||||
|
||||
return createOrganizationForUser(tx, {
|
||||
userId: req.userId!,
|
||||
createdById: req.userId!,
|
||||
name: body.data.name,
|
||||
logoUrl: body.data.logoUrl || null,
|
||||
makeDefault: currentMembershipCount === 0,
|
||||
});
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: organization.id,
|
||||
action: "ORGANIZATION_CREATE",
|
||||
category: "SYSTEM",
|
||||
level: "SUCCESS",
|
||||
message: `Organization "${organization.name}" created`,
|
||||
});
|
||||
|
||||
return reply.status(201).send({ success: true, data: organization });
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/:id",
|
||||
{ preHandler: [requireRole("SUPER_ADMIN")] },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = OrganizationUpdateSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const membership = await prisma.organizationMembership.findUnique({
|
||||
where: {
|
||||
organizationId_userId: {
|
||||
organizationId: id,
|
||||
userId: req.userId!,
|
||||
},
|
||||
},
|
||||
select: { organizationId: true },
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: "Organization not found",
|
||||
});
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.data.name ? { name: body.data.name } : {}),
|
||||
...(body.data.logoUrl !== undefined
|
||||
? { logoUrl: body.data.logoUrl || null }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: id,
|
||||
action: "ORGANIZATION_UPDATE",
|
||||
category: "SYSTEM",
|
||||
level: "INFO",
|
||||
message: `Organization "${organization.name}" updated`,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, data: organization });
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/:id/default",
|
||||
{ preHandler: [authenticate] },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const membership = await prisma.organizationMembership.findUnique({
|
||||
where: {
|
||||
organizationId_userId: {
|
||||
organizationId: id,
|
||||
userId: req.userId!,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: "Organization not found",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await setDefaultOrganizationForUser(tx, req.userId!, id);
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: id,
|
||||
action: "ORGANIZATION_SET_DEFAULT",
|
||||
category: "SYSTEM",
|
||||
level: "INFO",
|
||||
message: `Active organization changed to "${membership.organization.name}"`,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
id: membership.organization.id,
|
||||
name: membership.organization.name,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/:id/activate",
|
||||
{ preHandler: [authenticate] },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const membership = await prisma.organizationMembership.findUnique({
|
||||
where: {
|
||||
organizationId_userId: {
|
||||
organizationId: id,
|
||||
userId: req.userId!,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: "Organization not found",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.userId! },
|
||||
data: { activeOrganizationId: id },
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: id,
|
||||
action: "ORGANIZATION_ACTIVATE",
|
||||
category: "SYSTEM",
|
||||
level: "INFO",
|
||||
message: `Active organization switched to "${membership.organization.name}"`,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
id: membership.organization.id,
|
||||
name: membership.organization.name,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/:id",
|
||||
{ preHandler: [requireRole("SUPER_ADMIN")] },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = OrganizationDeleteSchema.safeParse(req.body);
|
||||
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const membership = await prisma.organizationMembership.findUnique({
|
||||
where: {
|
||||
organizationId_userId: {
|
||||
organizationId: id,
|
||||
userId: req.userId!,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
isDefault: true,
|
||||
},
|
||||
},
|
||||
servers: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: "Organization not found",
|
||||
});
|
||||
}
|
||||
|
||||
const remainingMemberships = await prisma.organizationMembership.count({
|
||||
where: {
|
||||
userId: req.userId!,
|
||||
organizationId: { not: id },
|
||||
},
|
||||
});
|
||||
|
||||
if (remainingMemberships === 0) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "You must keep at least one organization",
|
||||
});
|
||||
}
|
||||
|
||||
const serverIds = membership.organization.servers.map((server) => server.id);
|
||||
|
||||
for (const serverId of serverIds) {
|
||||
ssh.closeConnection(serverId);
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const member of membership.organization.members) {
|
||||
const fallbackMembership = await tx.organizationMembership.findFirst({
|
||||
where: {
|
||||
userId: member.userId,
|
||||
organizationId: { not: id },
|
||||
},
|
||||
orderBy: [{ isDefault: "desc" }, { createdAt: "asc" }],
|
||||
select: { organizationId: true },
|
||||
});
|
||||
|
||||
if (fallbackMembership) {
|
||||
if (member.isDefault) {
|
||||
await setDefaultOrganizationForUser(
|
||||
tx,
|
||||
member.userId,
|
||||
fallbackMembership.organizationId,
|
||||
);
|
||||
} else {
|
||||
await tx.user.updateMany({
|
||||
where: {
|
||||
id: member.userId,
|
||||
activeOrganizationId: id,
|
||||
},
|
||||
data: {
|
||||
activeOrganizationId: fallbackMembership.organizationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await tx.user.updateMany({
|
||||
where: {
|
||||
id: member.userId,
|
||||
activeOrganizationId: id,
|
||||
},
|
||||
data: { activeOrganizationId: null },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await tx.sslCert.deleteMany({
|
||||
where: {
|
||||
domain: {
|
||||
organizationId: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.domain.deleteMany({ where: { organizationId: id } });
|
||||
|
||||
if (serverIds.length > 0) {
|
||||
await tx.auditLog.updateMany({
|
||||
where: { serverId: { in: serverIds } },
|
||||
data: { serverId: null },
|
||||
});
|
||||
await tx.userServerAccess.deleteMany({
|
||||
where: { serverId: { in: serverIds } },
|
||||
});
|
||||
await tx.serverMetric.deleteMany({
|
||||
where: { serverId: { in: serverIds } },
|
||||
});
|
||||
await tx.container.deleteMany({
|
||||
where: { serverId: { in: serverIds } },
|
||||
});
|
||||
await tx.network.deleteMany({
|
||||
where: { serverId: { in: serverIds } },
|
||||
});
|
||||
await tx.firewallRulePreset.deleteMany({
|
||||
where: { serverId: { in: serverIds } },
|
||||
});
|
||||
await tx.appInstall.deleteMany({
|
||||
where: { serverId: { in: serverIds } },
|
||||
});
|
||||
await tx.backup.deleteMany({
|
||||
where: { serverId: { in: serverIds } },
|
||||
});
|
||||
await tx.server.deleteMany({ where: { id: { in: serverIds } } });
|
||||
}
|
||||
|
||||
await tx.auditLog.deleteMany({ where: { organizationId: id } });
|
||||
await tx.apiKey.deleteMany({ where: { organizationId: id } });
|
||||
await tx.userInvitation.deleteMany({ where: { organizationId: id } });
|
||||
await tx.organizationMembership.deleteMany({
|
||||
where: { organizationId: id },
|
||||
});
|
||||
|
||||
await tx.organization.delete({ where: { id } });
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
action: "ORGANIZATION_DELETE",
|
||||
category: "SYSTEM",
|
||||
level: "WARNING",
|
||||
message: `Organization "${membership.organization.name}" deleted with related records cleanup`,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Organization and related data deleted",
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
import { EnvironmentKind, Prisma } from "@prisma/client";
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma";
|
||||
import { authenticate, requireRole } from "../middleware/auth";
|
||||
import { auditLog } from "../services/audit.service";
|
||||
|
||||
type DbClient = typeof prisma | Prisma.TransactionClient;
|
||||
|
||||
const EnvironmentKindSchema = z.enum([
|
||||
"PRODUCTION",
|
||||
"STAGING",
|
||||
"DEVELOPMENT",
|
||||
"PREVIEW",
|
||||
"CUSTOM",
|
||||
]);
|
||||
|
||||
const ProjectEnvironmentInputSchema = z.object({
|
||||
name: z.string().trim().min(2).max(80),
|
||||
kind: EnvironmentKindSchema,
|
||||
serverId: z.string().trim().min(1),
|
||||
description: z.string().trim().max(240).optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
const ProjectCreateSchema = z.object({
|
||||
name: z.string().trim().min(2).max(80),
|
||||
description: z.string().trim().max(240).optional().or(z.literal("")),
|
||||
environments: z.array(ProjectEnvironmentInputSchema).max(12).default([]),
|
||||
});
|
||||
|
||||
const EnvironmentCreateSchema = ProjectEnvironmentInputSchema;
|
||||
|
||||
const EnvironmentUpdateSchema = ProjectEnvironmentInputSchema;
|
||||
|
||||
const EnvironmentContainersSchema = z.object({
|
||||
containerIds: z.array(z.string().trim().min(1)).max(200),
|
||||
});
|
||||
|
||||
const DeleteSchema = z.object({
|
||||
confirmation: z.literal("DELETE"),
|
||||
});
|
||||
|
||||
function slugify(value: string) {
|
||||
const slug = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
return slug || "item";
|
||||
}
|
||||
|
||||
async function generateUniqueProjectSlug(
|
||||
db: DbClient,
|
||||
organizationId: string,
|
||||
name: string,
|
||||
) {
|
||||
const base = slugify(name);
|
||||
let slug = base;
|
||||
let counter = 2;
|
||||
|
||||
while (
|
||||
await db.project.findFirst({
|
||||
where: { organizationId, slug },
|
||||
select: { id: true },
|
||||
})
|
||||
) {
|
||||
slug = `${base}-${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
async function generateUniqueEnvironmentSlug(
|
||||
db: DbClient,
|
||||
projectId: string,
|
||||
name: string,
|
||||
) {
|
||||
const base = slugify(name);
|
||||
let slug = base;
|
||||
let counter = 2;
|
||||
|
||||
while (
|
||||
await db.environment.findFirst({
|
||||
where: { projectId, slug },
|
||||
select: { id: true },
|
||||
})
|
||||
) {
|
||||
slug = `${base}-${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
function validateReservedEnvironmentKinds(
|
||||
environments: Array<{ kind: EnvironmentKind }>,
|
||||
) {
|
||||
const seen = new Set<EnvironmentKind>();
|
||||
|
||||
for (const environment of environments) {
|
||||
if (environment.kind === "CUSTOM") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.has(environment.kind)) {
|
||||
return environment.kind;
|
||||
}
|
||||
|
||||
seen.add(environment.kind);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureServersBelongToOrganization(
|
||||
organizationId: string,
|
||||
serverIds: string[],
|
||||
) {
|
||||
const uniqueServerIds = [...new Set(serverIds)];
|
||||
if (uniqueServerIds.length === 0) {
|
||||
return new Map<string, { id: string; name: string }>();
|
||||
}
|
||||
|
||||
const servers = await prisma.server.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
id: { in: uniqueServerIds },
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
if (servers.length !== uniqueServerIds.length) {
|
||||
throw new Error(
|
||||
"One or more selected servers are invalid for this organization",
|
||||
);
|
||||
}
|
||||
|
||||
return new Map(servers.map((server) => [server.id, server]));
|
||||
}
|
||||
|
||||
async function getProjectByIdForOrganization(
|
||||
projectId: string,
|
||||
organizationId: string,
|
||||
) {
|
||||
return prisma.project.findFirst({
|
||||
where: { id: projectId, organizationId },
|
||||
include: {
|
||||
environments: {
|
||||
orderBy: [{ createdAt: "asc" }],
|
||||
include: {
|
||||
server: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ip: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { containers: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { environments: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function serializeProject(
|
||||
project: NonNullable<
|
||||
Awaited<ReturnType<typeof getProjectByIdForOrganization>>
|
||||
>,
|
||||
) {
|
||||
const containersCount = project.environments.reduce(
|
||||
(total, environment) => total + environment._count.containers,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
organizationId: project.organizationId,
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
description: project.description,
|
||||
environmentCount: project._count.environments,
|
||||
containersCount,
|
||||
createdAt: project.createdAt,
|
||||
updatedAt: project.updatedAt,
|
||||
environments: project.environments.map((environment) => ({
|
||||
id: environment.id,
|
||||
projectId: environment.projectId,
|
||||
serverId: environment.serverId,
|
||||
name: environment.name,
|
||||
slug: environment.slug,
|
||||
kind: environment.kind,
|
||||
description: environment.description,
|
||||
containersCount: environment._count.containers,
|
||||
createdAt: environment.createdAt,
|
||||
updatedAt: environment.updatedAt,
|
||||
server: environment.server,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeEnvironment(
|
||||
environment: Prisma.EnvironmentGetPayload<{
|
||||
include: {
|
||||
server: {
|
||||
select: {
|
||||
id: true;
|
||||
name: true;
|
||||
ip: true;
|
||||
status: true;
|
||||
};
|
||||
};
|
||||
_count: {
|
||||
select: { containers: true };
|
||||
};
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: environment.id,
|
||||
projectId: environment.projectId,
|
||||
serverId: environment.serverId,
|
||||
name: environment.name,
|
||||
slug: environment.slug,
|
||||
kind: environment.kind,
|
||||
description: environment.description,
|
||||
containersCount: environment._count.containers,
|
||||
createdAt: environment.createdAt,
|
||||
updatedAt: environment.updatedAt,
|
||||
server: environment.server,
|
||||
};
|
||||
}
|
||||
|
||||
export async function projectsRoutes(app: FastifyInstance) {
|
||||
app.get("/", { preHandler: [authenticate] }, async (req, reply) => {
|
||||
const organizationId = req.organizationId;
|
||||
if (!organizationId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Active organization is required",
|
||||
});
|
||||
}
|
||||
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
include: {
|
||||
environments: {
|
||||
orderBy: [{ createdAt: "asc" }],
|
||||
include: {
|
||||
server: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ip: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { containers: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { environments: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: projects.map(serializeProject),
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/:id", { preHandler: [authenticate] }, async (req, reply) => {
|
||||
const organizationId = req.organizationId;
|
||||
if (!organizationId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Active organization is required",
|
||||
});
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const project = await getProjectByIdForOrganization(id, organizationId);
|
||||
|
||||
if (!project) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Project not found" });
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: serializeProject(project),
|
||||
});
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/",
|
||||
{ preHandler: [requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const organizationId = req.organizationId;
|
||||
if (!organizationId) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: "Active organization is required" });
|
||||
}
|
||||
|
||||
const body = ProjectCreateSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const duplicateKind = validateReservedEnvironmentKinds(
|
||||
body.data.environments,
|
||||
);
|
||||
if (duplicateKind) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: `Environment kind ${duplicateKind} can only be used once per project`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureServersBelongToOrganization(
|
||||
organizationId,
|
||||
body.data.environments.map((environment) => environment.serverId),
|
||||
);
|
||||
|
||||
const project = await prisma.$transaction(async (tx) => {
|
||||
const createdProject = await tx.project.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: body.data.name,
|
||||
slug: await generateUniqueProjectSlug(
|
||||
tx,
|
||||
organizationId,
|
||||
body.data.name,
|
||||
),
|
||||
description: body.data.description || null,
|
||||
},
|
||||
});
|
||||
|
||||
for (const environment of body.data.environments) {
|
||||
await tx.environment.create({
|
||||
data: {
|
||||
projectId: createdProject.id,
|
||||
serverId: environment.serverId,
|
||||
name: environment.name,
|
||||
slug: await generateUniqueEnvironmentSlug(
|
||||
tx,
|
||||
createdProject.id,
|
||||
environment.name,
|
||||
),
|
||||
kind: environment.kind,
|
||||
description: environment.description || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return createdProject;
|
||||
});
|
||||
|
||||
const freshProject = await getProjectByIdForOrganization(
|
||||
project.id,
|
||||
organizationId,
|
||||
);
|
||||
if (!freshProject) {
|
||||
throw new Error("Project created but could not be loaded");
|
||||
}
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId,
|
||||
action: "PROJECT_CREATE",
|
||||
category: "SYSTEM",
|
||||
level: "SUCCESS",
|
||||
message: `Project \"${freshProject.name}\" created with ${freshProject.environments.length} environment(s)`,
|
||||
});
|
||||
|
||||
return reply
|
||||
.status(201)
|
||||
.send({ success: true, data: serializeProject(freshProject) });
|
||||
} catch (error) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to create project",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/:id/environments",
|
||||
{ preHandler: [requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const organizationId = req.organizationId;
|
||||
if (!organizationId) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: "Active organization is required" });
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const body = EnvironmentCreateSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id, organizationId },
|
||||
include: { environments: { select: { kind: true } } },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Project not found" });
|
||||
}
|
||||
|
||||
if (
|
||||
body.data.kind !== "CUSTOM" &&
|
||||
project.environments.some(
|
||||
(environment) => environment.kind === body.data.kind,
|
||||
)
|
||||
) {
|
||||
return reply.status(409).send({
|
||||
success: false,
|
||||
error: `Environment kind ${body.data.kind} already exists in this project`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureServersBelongToOrganization(organizationId, [
|
||||
body.data.serverId,
|
||||
]);
|
||||
|
||||
const environment = await prisma.environment.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
serverId: body.data.serverId,
|
||||
name: body.data.name,
|
||||
slug: await generateUniqueEnvironmentSlug(
|
||||
prisma,
|
||||
project.id,
|
||||
body.data.name,
|
||||
),
|
||||
kind: body.data.kind,
|
||||
description: body.data.description || null,
|
||||
},
|
||||
include: {
|
||||
server: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ip: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { containers: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId,
|
||||
action: "PROJECT_ENVIRONMENT_CREATE",
|
||||
category: "SYSTEM",
|
||||
level: "INFO",
|
||||
message: `Environment \"${environment.name}\" added to project \"${project.name}\"`,
|
||||
});
|
||||
|
||||
return reply.status(201).send({
|
||||
success: true,
|
||||
data: serializeEnvironment(environment),
|
||||
});
|
||||
} catch (error) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to create environment",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/environments/:environmentId",
|
||||
{ preHandler: [requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const organizationId = req.organizationId;
|
||||
if (!organizationId) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: "Active organization is required" });
|
||||
}
|
||||
|
||||
const { environmentId } = req.params as { environmentId: string };
|
||||
const body = EnvironmentUpdateSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const environment = await prisma.environment.findFirst({
|
||||
where: {
|
||||
id: environmentId,
|
||||
project: { organizationId },
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
environments: {
|
||||
where: { id: { not: environmentId } },
|
||||
select: { kind: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: { select: { containers: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Environment not found" });
|
||||
}
|
||||
|
||||
if (
|
||||
body.data.kind !== "CUSTOM" &&
|
||||
environment.project.environments.some(
|
||||
(projectEnvironment) => projectEnvironment.kind === body.data.kind,
|
||||
)
|
||||
) {
|
||||
return reply.status(409).send({
|
||||
success: false,
|
||||
error: `Environment kind ${body.data.kind} already exists in this project`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureServersBelongToOrganization(organizationId, [
|
||||
body.data.serverId,
|
||||
]);
|
||||
|
||||
if (
|
||||
body.data.serverId !== environment.serverId &&
|
||||
environment._count.containers > 0
|
||||
) {
|
||||
return reply.status(409).send({
|
||||
success: false,
|
||||
error:
|
||||
"Detach containers before moving this environment to another server.",
|
||||
});
|
||||
}
|
||||
|
||||
const updatedEnvironment = await prisma.environment.update({
|
||||
where: { id: environment.id },
|
||||
data: {
|
||||
serverId: body.data.serverId,
|
||||
name: body.data.name,
|
||||
slug:
|
||||
body.data.name === environment.name
|
||||
? environment.slug
|
||||
: await generateUniqueEnvironmentSlug(
|
||||
prisma,
|
||||
environment.projectId,
|
||||
body.data.name,
|
||||
),
|
||||
kind: body.data.kind,
|
||||
description: body.data.description || null,
|
||||
},
|
||||
include: {
|
||||
server: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ip: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { containers: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId,
|
||||
action: "PROJECT_ENVIRONMENT_UPDATE",
|
||||
category: "SYSTEM",
|
||||
level: "INFO",
|
||||
message: `Environment \"${updatedEnvironment.name}\" updated in project \"${environment.project.name}\"`,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: serializeEnvironment(updatedEnvironment),
|
||||
});
|
||||
} catch (error) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update environment",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/environments/:environmentId/containers",
|
||||
{ preHandler: [requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const organizationId = req.organizationId;
|
||||
if (!organizationId) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: "Active organization is required" });
|
||||
}
|
||||
|
||||
const { environmentId } = req.params as { environmentId: string };
|
||||
const body = EnvironmentContainersSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const environment = await prisma.environment.findFirst({
|
||||
where: {
|
||||
id: environmentId,
|
||||
project: { organizationId },
|
||||
},
|
||||
include: {
|
||||
project: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Environment not found" });
|
||||
}
|
||||
|
||||
const containerIds = [...new Set(body.data.containerIds)];
|
||||
const selectedContainers =
|
||||
containerIds.length > 0
|
||||
? await prisma.container.findMany({
|
||||
where: {
|
||||
id: { in: containerIds },
|
||||
serverId: environment.serverId,
|
||||
server: { organizationId },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
if (selectedContainers.length !== containerIds.length) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error:
|
||||
"One or more selected containers are invalid for this environment server.",
|
||||
});
|
||||
}
|
||||
|
||||
const updatedEnvironment = await prisma.$transaction(async (tx) => {
|
||||
await tx.container.updateMany({
|
||||
where: {
|
||||
environmentId: environment.id,
|
||||
...(containerIds.length > 0 ? { id: { notIn: containerIds } } : {}),
|
||||
},
|
||||
data: { environmentId: null },
|
||||
});
|
||||
|
||||
if (containerIds.length > 0) {
|
||||
await tx.container.updateMany({
|
||||
where: {
|
||||
id: { in: containerIds },
|
||||
serverId: environment.serverId,
|
||||
server: { organizationId },
|
||||
},
|
||||
data: { environmentId: environment.id },
|
||||
});
|
||||
}
|
||||
|
||||
return tx.environment.findUniqueOrThrow({
|
||||
where: { id: environment.id },
|
||||
include: {
|
||||
server: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ip: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { containers: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId,
|
||||
action: "PROJECT_ENVIRONMENT_CONTAINERS_UPDATE",
|
||||
category: "SYSTEM",
|
||||
level: "INFO",
|
||||
message: `Environment \"${environment.name}\" container assignments updated in project \"${environment.project.name}\"`,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: serializeEnvironment(updatedEnvironment),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/:id",
|
||||
{ preHandler: [requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const organizationId = req.organizationId;
|
||||
if (!organizationId) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: "Active organization is required" });
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
const body = DeleteSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id, organizationId },
|
||||
include: {
|
||||
environments: {
|
||||
select: {
|
||||
_count: { select: { containers: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Project not found" });
|
||||
}
|
||||
|
||||
const attachedContainers = project.environments.reduce(
|
||||
(total, environment) => total + environment._count.containers,
|
||||
0,
|
||||
);
|
||||
const attachedEnvironments = project.environments.length;
|
||||
|
||||
if (attachedEnvironments > 0) {
|
||||
return reply.status(409).send({
|
||||
success: false,
|
||||
error:
|
||||
"Project still has environments. Delete all environments before deleting this project.",
|
||||
});
|
||||
}
|
||||
|
||||
if (attachedContainers > 0) {
|
||||
return reply.status(409).send({
|
||||
success: false,
|
||||
error:
|
||||
"Project still has environments linked to containers. Reassign or remove those containers first.",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.project.delete({ where: { id: project.id } });
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId,
|
||||
action: "PROJECT_DELETE",
|
||||
category: "SYSTEM",
|
||||
level: "WARNING",
|
||||
message: `Project \"${project.name}\" deleted`,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, message: "Project deleted" });
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/environments/:environmentId",
|
||||
{ preHandler: [requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const organizationId = req.organizationId;
|
||||
if (!organizationId) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: "Active organization is required" });
|
||||
}
|
||||
|
||||
const { environmentId } = req.params as { environmentId: string };
|
||||
const body = DeleteSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const environment = await prisma.environment.findFirst({
|
||||
where: {
|
||||
id: environmentId,
|
||||
project: { organizationId },
|
||||
},
|
||||
include: {
|
||||
project: { select: { name: true } },
|
||||
_count: { select: { containers: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Environment not found" });
|
||||
}
|
||||
|
||||
if (environment._count.containers > 0) {
|
||||
return reply.status(409).send({
|
||||
success: false,
|
||||
error:
|
||||
"Environment is still linked to containers. Reassign or remove those containers first.",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.environment.delete({ where: { id: environment.id } });
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId,
|
||||
action: "PROJECT_ENVIRONMENT_DELETE",
|
||||
category: "SYSTEM",
|
||||
level: "WARNING",
|
||||
message: `Environment \"${environment.name}\" removed from project \"${environment.project.name}\"`,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, message: "Environment deleted" });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma";
|
||||
import { authenticate, requireApiKeyPermission } from "../middleware/auth";
|
||||
import { auditLog } from "../services/audit.service";
|
||||
import * as ssh from "../services/ssh.service";
|
||||
|
||||
const SECURITY_OVERVIEW_FETCH_TIMEOUT_MS = 5000;
|
||||
|
||||
const securityReadAccess = [
|
||||
authenticate,
|
||||
requireApiKeyPermission("read:security"),
|
||||
];
|
||||
|
||||
const securityWriteAccess = [
|
||||
authenticate,
|
||||
requireApiKeyPermission("write:security"),
|
||||
];
|
||||
|
||||
function emptySecurityOverviewItem(
|
||||
server: {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
status: string;
|
||||
},
|
||||
error: string | null,
|
||||
) {
|
||||
return {
|
||||
server: {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
ip: server.ip,
|
||||
status: server.status,
|
||||
},
|
||||
firewall: {
|
||||
enabled: false,
|
||||
rulesCount: 0,
|
||||
},
|
||||
fail2ban: {
|
||||
enabled: false,
|
||||
installed: false,
|
||||
bannedCount: 0,
|
||||
},
|
||||
platform: {
|
||||
distro: null,
|
||||
packageManager: null,
|
||||
sudoNonInteractive: false,
|
||||
supportedForFail2banInstall: false,
|
||||
},
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchSecurityOverviewItem(server: {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
status: string;
|
||||
}) {
|
||||
if (server.status === "OFFLINE") {
|
||||
return emptySecurityOverviewItem(server, "Server is offline");
|
||||
}
|
||||
|
||||
try {
|
||||
const [firewall, fail2ban, platform] = await Promise.all([
|
||||
ssh.getFirewallStatus(server as any),
|
||||
ssh.getFail2banStatus(server as any),
|
||||
ssh.detectServerPlatform(server as any),
|
||||
]);
|
||||
|
||||
return {
|
||||
server: {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
ip: server.ip,
|
||||
status: server.status,
|
||||
},
|
||||
firewall: {
|
||||
enabled: firewall.enabled,
|
||||
rulesCount: firewall.rules.length,
|
||||
},
|
||||
fail2ban: {
|
||||
enabled: fail2ban.enabled,
|
||||
installed: fail2ban.installed,
|
||||
bannedCount: fail2ban.bannedIPs.length,
|
||||
},
|
||||
platform,
|
||||
error: null,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return emptySecurityOverviewItem(
|
||||
server,
|
||||
err?.message || "Failed to fetch security status",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function withSecurityOverviewTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
fallback: T,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const complete = (value: T) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
complete(fallback);
|
||||
}, SECURITY_OVERVIEW_FETCH_TIMEOUT_MS);
|
||||
|
||||
void promise
|
||||
.then((value) => {
|
||||
clearTimeout(timeoutId);
|
||||
complete(value);
|
||||
})
|
||||
.catch(() => {
|
||||
clearTimeout(timeoutId);
|
||||
complete(fallback);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeFirewallSource(source?: string) {
|
||||
const value = source?.trim();
|
||||
return value ? value : "Anywhere";
|
||||
}
|
||||
|
||||
function normalizeFirewallRuleForRestore(rule: string) {
|
||||
return rule.replace(/\s*\(v6\)$/i, "").trim();
|
||||
}
|
||||
|
||||
function normalizeFirewallSourceForRestore(source?: string) {
|
||||
const normalizedSource = normalizeFirewallSource(source).replace(
|
||||
/\s*\(v6\)$/i,
|
||||
"",
|
||||
);
|
||||
|
||||
return normalizedSource === "Anywhere" ? undefined : normalizedSource;
|
||||
}
|
||||
|
||||
async function listDisabledFirewallRules(serverId: string) {
|
||||
const presets = await prisma.firewallRulePreset.findMany({
|
||||
where: { serverId },
|
||||
orderBy: [{ updatedAt: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
|
||||
return presets.map((preset) => ({
|
||||
id: preset.id,
|
||||
number: null,
|
||||
rule: preset.rule,
|
||||
action: preset.action,
|
||||
direction: preset.direction,
|
||||
from: preset.source,
|
||||
description: preset.description,
|
||||
enabled: false,
|
||||
}));
|
||||
}
|
||||
|
||||
async function findOrganizationServer(
|
||||
serverId: string,
|
||||
organizationId: string,
|
||||
) {
|
||||
return prisma.server.findFirst({
|
||||
where: { id: serverId, organizationId },
|
||||
});
|
||||
}
|
||||
|
||||
function mapActiveFirewallRules(rules: ssh.FirewallRule[]) {
|
||||
return rules.map((rule) => ({
|
||||
id: null,
|
||||
number: rule.number,
|
||||
rule: rule.rule,
|
||||
action: rule.action,
|
||||
direction: rule.direction,
|
||||
from: rule.from,
|
||||
description: rule.description,
|
||||
enabled: true,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function securityRoutes(app: FastifyInstance) {
|
||||
app.get(
|
||||
"/overview",
|
||||
{ preHandler: securityReadAccess },
|
||||
async (req, reply) => {
|
||||
const servers = await prisma.server.findMany({
|
||||
where: { organizationId: req.organizationId! },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
const data = await Promise.all(
|
||||
servers.map((server) =>
|
||||
withSecurityOverviewTimeout(
|
||||
fetchSecurityOverviewItem(server),
|
||||
emptySecurityOverviewItem(server, "Security check timed out"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return reply.send({ success: true, data });
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/:serverId",
|
||||
{ preHandler: securityReadAccess },
|
||||
async (req, reply) => {
|
||||
const { serverId } = req.params as { serverId: string };
|
||||
const server = await findOrganizationServer(
|
||||
serverId,
|
||||
req.organizationId!,
|
||||
);
|
||||
|
||||
if (!server) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
}
|
||||
|
||||
if (server.status === "OFFLINE") {
|
||||
return reply.status(409).send({
|
||||
success: false,
|
||||
error: "Server is offline",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const [firewall, fail2ban, platform] = await Promise.all([
|
||||
ssh.getFirewallStatus(server as any),
|
||||
ssh.getFail2banStatus(server as any),
|
||||
ssh.detectServerPlatform(server as any),
|
||||
]);
|
||||
|
||||
const disabledRules = await listDisabledFirewallRules(server.id);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
server: {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
ip: server.ip,
|
||||
status: server.status,
|
||||
},
|
||||
firewall: {
|
||||
enabled: firewall.enabled,
|
||||
rules: [
|
||||
...mapActiveFirewallRules(firewall.rules),
|
||||
...disabledRules,
|
||||
],
|
||||
},
|
||||
fail2ban,
|
||||
platform,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: err?.message || "Failed to fetch security status",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// GET /security/:serverId/firewall
|
||||
app.get(
|
||||
"/:serverId/firewall",
|
||||
{ preHandler: securityReadAccess },
|
||||
async (req, reply) => {
|
||||
const { serverId } = req.params as { serverId: string };
|
||||
const server = await findOrganizationServer(
|
||||
serverId,
|
||||
req.organizationId!,
|
||||
);
|
||||
if (!server)
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
|
||||
try {
|
||||
const status = await ssh.getFirewallStatus(server);
|
||||
const disabledRules = await listDisabledFirewallRules(server.id);
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
enabled: status.enabled,
|
||||
rules: [...mapActiveFirewallRules(status.rules), ...disabledRules],
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
return reply.status(500).send({ success: false, error: err.message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// POST /security/:serverId/firewall/enable
|
||||
app.post(
|
||||
"/:serverId/firewall/enable",
|
||||
{ preHandler: securityWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { serverId } = req.params as { serverId: string };
|
||||
const server = await findOrganizationServer(
|
||||
serverId,
|
||||
req.organizationId!,
|
||||
);
|
||||
if (!server)
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
|
||||
await ssh.enableFirewall(server);
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId,
|
||||
action: "UFW_ENABLE",
|
||||
category: "SECURITY",
|
||||
level: "INFO",
|
||||
message: `UFW enabled on "${server.name}"`,
|
||||
});
|
||||
return reply.send({ success: true, message: "Firewall enabled" });
|
||||
},
|
||||
);
|
||||
|
||||
// POST /security/:serverId/firewall/disable
|
||||
app.post(
|
||||
"/:serverId/firewall/disable",
|
||||
{ preHandler: securityWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { serverId } = req.params as { serverId: string };
|
||||
const server = await findOrganizationServer(
|
||||
serverId,
|
||||
req.organizationId!,
|
||||
);
|
||||
if (!server)
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
|
||||
await ssh.disableFirewall(server);
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId,
|
||||
action: "UFW_DISABLE",
|
||||
category: "SECURITY",
|
||||
level: "WARNING",
|
||||
message: `UFW disabled on "${server.name}"`,
|
||||
});
|
||||
return reply.send({ success: true, message: "Firewall disabled" });
|
||||
},
|
||||
);
|
||||
|
||||
// POST /security/:serverId/firewall/rules — add rule
|
||||
app.post(
|
||||
"/:serverId/firewall/rules",
|
||||
{ preHandler: securityWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { serverId } = req.params as { serverId: string };
|
||||
const body = z
|
||||
.object({
|
||||
rule: z.string(),
|
||||
action: z.enum(["allow", "deny"]),
|
||||
from: z.string().optional(),
|
||||
})
|
||||
.safeParse(req.body);
|
||||
if (!body.success)
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
|
||||
const server = await findOrganizationServer(
|
||||
serverId,
|
||||
req.organizationId!,
|
||||
);
|
||||
if (!server)
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
|
||||
await ssh.addFirewallRule(
|
||||
server,
|
||||
body.data.rule,
|
||||
body.data.action,
|
||||
body.data.from,
|
||||
);
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId,
|
||||
action: "UFW_RULE_ADD",
|
||||
category: "SECURITY",
|
||||
level: "INFO",
|
||||
message: `Firewall rule added: ${body.data.action} ${body.data.rule} on "${server.name}"`,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, message: "Firewall rule added" });
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /security/:serverId/firewall/rules/:ruleNum
|
||||
app.delete(
|
||||
"/:serverId/firewall/rules/:ruleNum",
|
||||
{ preHandler: securityWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { serverId, ruleNum } = req.params as {
|
||||
serverId: string;
|
||||
ruleNum: string;
|
||||
};
|
||||
const server = await findOrganizationServer(
|
||||
serverId,
|
||||
req.organizationId!,
|
||||
);
|
||||
if (!server)
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
|
||||
await ssh.deleteFirewallRule(server, parseInt(ruleNum));
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId,
|
||||
action: "UFW_RULE_DELETE",
|
||||
category: "SECURITY",
|
||||
level: "WARNING",
|
||||
message: `Firewall rule #${ruleNum} deleted on "${server.name}"`,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, message: "Firewall rule deleted" });
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/:serverId/firewall/rules/:ruleNum/disable",
|
||||
{ preHandler: securityWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { serverId, ruleNum } = req.params as {
|
||||
serverId: string;
|
||||
ruleNum: string;
|
||||
};
|
||||
const body = z
|
||||
.object({
|
||||
rule: z.string().min(1),
|
||||
action: z.enum(["allow", "deny"]),
|
||||
direction: z.string().default("IN"),
|
||||
from: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
.safeParse(req.body);
|
||||
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const server = await findOrganizationServer(
|
||||
serverId,
|
||||
req.organizationId!,
|
||||
);
|
||||
if (!server) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
}
|
||||
|
||||
const source = normalizeFirewallSource(body.data.from);
|
||||
|
||||
await prisma.firewallRulePreset.upsert({
|
||||
where: {
|
||||
serverId_rule_action_direction_source: {
|
||||
serverId,
|
||||
rule: body.data.rule,
|
||||
action: body.data.action.toUpperCase(),
|
||||
direction: body.data.direction.toUpperCase(),
|
||||
source,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
description: body.data.description ?? "",
|
||||
},
|
||||
create: {
|
||||
serverId,
|
||||
rule: body.data.rule,
|
||||
action: body.data.action.toUpperCase(),
|
||||
direction: body.data.direction.toUpperCase(),
|
||||
source,
|
||||
description: body.data.description ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
await ssh.deleteFirewallRule(server, parseInt(ruleNum, 10));
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId,
|
||||
action: "UFW_RULE_DISABLE",
|
||||
category: "SECURITY",
|
||||
level: "WARNING",
|
||||
message: `Firewall rule #${ruleNum} disabled on "${server.name}"`,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, message: "Firewall rule disabled" });
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/:serverId/firewall/rules/saved/:ruleId/enable",
|
||||
{ preHandler: securityWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { serverId, ruleId } = req.params as {
|
||||
serverId: string;
|
||||
ruleId: string;
|
||||
};
|
||||
|
||||
const [server, preset] = await Promise.all([
|
||||
findOrganizationServer(serverId, req.organizationId!),
|
||||
prisma.firewallRulePreset.findUnique({ where: { id: ruleId } }),
|
||||
]);
|
||||
|
||||
if (!server) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
}
|
||||
|
||||
if (!preset || preset.serverId !== serverId) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Saved firewall rule not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
await ssh.addFirewallRule(
|
||||
server,
|
||||
normalizeFirewallRuleForRestore(preset.rule),
|
||||
preset.action.toLowerCase() as "allow" | "deny",
|
||||
normalizeFirewallSourceForRestore(preset.source),
|
||||
);
|
||||
await prisma.firewallRulePreset.delete({ where: { id: ruleId } });
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId,
|
||||
action: "UFW_RULE_ENABLE",
|
||||
category: "SECURITY",
|
||||
level: "INFO",
|
||||
message: `Saved firewall rule restored on "${server.name}"`,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, message: "Firewall rule enabled" });
|
||||
} catch (err: unknown) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to enable saved firewall rule",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/:serverId/firewall/rules/saved/:ruleId",
|
||||
{ preHandler: securityWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { serverId, ruleId } = req.params as {
|
||||
serverId: string;
|
||||
ruleId: string;
|
||||
};
|
||||
|
||||
const [server, preset] = await Promise.all([
|
||||
findOrganizationServer(serverId, req.organizationId!),
|
||||
prisma.firewallRulePreset.findUnique({ where: { id: ruleId } }),
|
||||
]);
|
||||
|
||||
if (!server) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
}
|
||||
|
||||
if (!preset || preset.serverId !== serverId) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Saved firewall rule not found" });
|
||||
}
|
||||
|
||||
await prisma.firewallRulePreset.delete({ where: { id: ruleId } });
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId,
|
||||
action: "UFW_RULE_PRESET_DELETE",
|
||||
category: "SECURITY",
|
||||
level: "WARNING",
|
||||
message: `Saved disabled firewall rule removed on server ${serverId}`,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Saved firewall rule removed",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// GET /security/:serverId/fail2ban
|
||||
app.get(
|
||||
"/:serverId/fail2ban",
|
||||
{ preHandler: securityReadAccess },
|
||||
async (req, reply) => {
|
||||
const { serverId } = req.params as { serverId: string };
|
||||
const server = await findOrganizationServer(
|
||||
serverId,
|
||||
req.organizationId!,
|
||||
);
|
||||
if (!server)
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
|
||||
try {
|
||||
const status = await ssh.getFail2banStatus(server);
|
||||
return reply.send({ success: true, data: status });
|
||||
} catch (err: any) {
|
||||
return reply.status(500).send({ success: false, error: err.message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/:serverId/fail2ban/enable",
|
||||
{ preHandler: securityWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { serverId } = req.params as { serverId: string };
|
||||
const server = await findOrganizationServer(
|
||||
serverId,
|
||||
req.organizationId!,
|
||||
);
|
||||
if (!server)
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
|
||||
await ssh.enableFail2ban(server);
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
serverId,
|
||||
action: "FAIL2BAN_ENABLE",
|
||||
category: "SECURITY",
|
||||
level: "INFO",
|
||||
message: `Fail2ban enabled on "${server.name}"`,
|
||||
});
|
||||
return reply.send({ success: true, message: "Fail2ban enabled" });
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/:serverId/fail2ban/disable",
|
||||
{ preHandler: securityWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { serverId } = req.params as { serverId: string };
|
||||
const server = await findOrganizationServer(
|
||||
serverId,
|
||||
req.organizationId!,
|
||||
);
|
||||
if (!server)
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
|
||||
await ssh.disableFail2ban(server);
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
serverId,
|
||||
action: "FAIL2BAN_DISABLE",
|
||||
category: "SECURITY",
|
||||
level: "WARNING",
|
||||
message: `Fail2ban disabled on "${server.name}"`,
|
||||
});
|
||||
return reply.send({ success: true, message: "Fail2ban disabled" });
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/:serverId/fail2ban/install",
|
||||
{ preHandler: securityWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { serverId } = req.params as { serverId: string };
|
||||
const server = await findOrganizationServer(
|
||||
serverId,
|
||||
req.organizationId!,
|
||||
);
|
||||
if (!server)
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
|
||||
await ssh.installFail2ban(server);
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
serverId,
|
||||
action: "FAIL2BAN_INSTALL",
|
||||
category: "SECURITY",
|
||||
level: "INFO",
|
||||
message: `Fail2ban installed and enabled on "${server.name}"`,
|
||||
});
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Fail2ban installed and enabled",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// POST /security/:serverId/fail2ban/unban
|
||||
app.post(
|
||||
"/:serverId/fail2ban/unban",
|
||||
{ preHandler: securityWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { serverId } = req.params as { serverId: string };
|
||||
const body = z
|
||||
.object({
|
||||
ip: z.union([z.ipv4(), z.ipv6()]),
|
||||
jail: z.string().default("sshd"),
|
||||
})
|
||||
.safeParse(req.body);
|
||||
if (!body.success)
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
|
||||
const server = await findOrganizationServer(
|
||||
serverId,
|
||||
req.organizationId!,
|
||||
);
|
||||
if (!server)
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Server not found" });
|
||||
|
||||
await ssh.unbanIP(server, body.data.ip, body.data.jail);
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
serverId,
|
||||
action: "FAIL2BAN_UNBAN",
|
||||
category: "SECURITY",
|
||||
level: "INFO",
|
||||
message: `IP ${body.data.ip} unbanned on "${server.name}"`,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: `IP ${body.data.ip} unbanned`,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,618 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma";
|
||||
import { decrypt, encrypt } from "../lib/crypto";
|
||||
import { authenticate, requireRole } from "../middleware/auth";
|
||||
import { auditLog } from "../services/audit.service";
|
||||
import { verifyAwsS3Connection } from "../services/integration-verification.service";
|
||||
import { userCanAccessServer } from "../services/server-access.service";
|
||||
|
||||
const STORAGE_PROVIDER_TYPES = [
|
||||
"awsS3",
|
||||
"alibabaOss",
|
||||
"arvanAos",
|
||||
"ceph",
|
||||
"chinaMobileEos",
|
||||
"cloudflareR2",
|
||||
"digitalOceanSpaces",
|
||||
"dreamObjects",
|
||||
"googleCloudStorage",
|
||||
"huaweiObs",
|
||||
"ibmCos",
|
||||
"custom",
|
||||
] as const;
|
||||
|
||||
const STORAGE_PROVIDER_TO_DB = {
|
||||
awsS3: "AWS_S3",
|
||||
alibabaOss: "ALIBABA_OSS",
|
||||
arvanAos: "ARVAN_AOS",
|
||||
ceph: "CEPH",
|
||||
chinaMobileEos: "CHINA_MOBILE_EOS",
|
||||
cloudflareR2: "CLOUDFLARE_R2",
|
||||
digitalOceanSpaces: "DIGITALOCEAN_SPACES",
|
||||
dreamObjects: "DREAMOBJECTS",
|
||||
googleCloudStorage: "GOOGLE_CLOUD_STORAGE",
|
||||
huaweiObs: "HUAWEI_OBS",
|
||||
ibmCos: "IBM_COS",
|
||||
custom: "CUSTOM",
|
||||
} as const;
|
||||
|
||||
const DB_TO_STORAGE_PROVIDER = Object.fromEntries(
|
||||
Object.entries(STORAGE_PROVIDER_TO_DB).map(([key, value]) => [value, key]),
|
||||
) as Record<
|
||||
(typeof STORAGE_PROVIDER_TO_DB)[keyof typeof STORAGE_PROVIDER_TO_DB],
|
||||
(typeof STORAGE_PROVIDER_TYPES)[number]
|
||||
>;
|
||||
|
||||
const StorageDestinationSchema = z.object({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
provider: z.enum(STORAGE_PROVIDER_TYPES),
|
||||
enabled: z.boolean(),
|
||||
accessKeyId: z.string().trim().min(1).max(256),
|
||||
secretAccessKey: z.string().trim().max(512).optional().or(z.literal("")),
|
||||
region: z.string().trim().min(1).max(128),
|
||||
bucket: z.string().trim().min(1).max(128),
|
||||
endpoint: z.string().trim().url().max(2048).optional().or(z.literal("")),
|
||||
additionalFlags: z.array(z.string().trim().min(1).max(255)).max(12),
|
||||
serverId: z.string().trim().max(64).optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
const StorageDestinationVerifySchema = StorageDestinationSchema.extend({
|
||||
id: z.string().trim().max(64).optional(),
|
||||
});
|
||||
|
||||
type StorageDestinationInput = z.infer<typeof StorageDestinationSchema>;
|
||||
type StorageProviderDb =
|
||||
(typeof STORAGE_PROVIDER_TO_DB)[keyof typeof STORAGE_PROVIDER_TO_DB];
|
||||
type StorageDestinationRecord = {
|
||||
id: string;
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
serverId: string | null;
|
||||
name: string;
|
||||
provider: StorageProviderDb;
|
||||
enabled: boolean;
|
||||
accessKeyId: string;
|
||||
secretAccessKeyEnc: string | null;
|
||||
region: string | null;
|
||||
bucket: string;
|
||||
endpoint: string | null;
|
||||
additionalFlags: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
server?: {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const prismaStorage = prisma as typeof prisma & {
|
||||
userStorageDestination: {
|
||||
findMany: (args: unknown) => Promise<StorageDestinationRecord[]>;
|
||||
findFirst: (args: unknown) => Promise<StorageDestinationRecord | null>;
|
||||
create: (args: unknown) => Promise<StorageDestinationRecord>;
|
||||
update: (args: unknown) => Promise<StorageDestinationRecord>;
|
||||
delete: (args: unknown) => Promise<StorageDestinationRecord>;
|
||||
};
|
||||
};
|
||||
|
||||
function toTrimmedValue(value?: string | null) {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function serializeDestination(destination: StorageDestinationRecord) {
|
||||
return {
|
||||
id: destination.id,
|
||||
name: destination.name,
|
||||
provider: DB_TO_STORAGE_PROVIDER[destination.provider],
|
||||
enabled: destination.enabled,
|
||||
accessKeyId: destination.accessKeyId,
|
||||
secretAccessKey: "",
|
||||
hasSecretAccessKey: Boolean(destination.secretAccessKeyEnc),
|
||||
region: destination.region ?? "",
|
||||
bucket: destination.bucket,
|
||||
endpoint: destination.endpoint ?? "",
|
||||
additionalFlags: destination.additionalFlags,
|
||||
serverId: destination.serverId,
|
||||
targetServer: destination.server ?? null,
|
||||
createdAt: destination.createdAt.toISOString(),
|
||||
updatedAt: destination.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function validateDestinationInput(
|
||||
input: StorageDestinationInput,
|
||||
hasStoredSecret: boolean,
|
||||
) {
|
||||
if (input.provider !== "awsS3" && !toTrimmedValue(input.endpoint)) {
|
||||
return "Endpoint is required for S3-compatible providers other than AWS S3";
|
||||
}
|
||||
|
||||
if (!toTrimmedValue(input.secretAccessKey) && !hasStoredSecret) {
|
||||
return "Secret access key is required";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureServerAccess(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
serverId?: string | null,
|
||||
) {
|
||||
const normalizedServerId = toTrimmedValue(serverId);
|
||||
if (!normalizedServerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canAccess = await userCanAccessServer(
|
||||
userId,
|
||||
normalizedServerId,
|
||||
organizationId,
|
||||
);
|
||||
|
||||
return canAccess ? normalizedServerId : false;
|
||||
}
|
||||
|
||||
function buildDestinationWriteData(
|
||||
input: StorageDestinationInput,
|
||||
existing?: StorageDestinationRecord | null,
|
||||
) {
|
||||
const normalizedSecret = toTrimmedValue(input.secretAccessKey);
|
||||
|
||||
return {
|
||||
name: toTrimmedValue(input.name),
|
||||
provider: STORAGE_PROVIDER_TO_DB[input.provider],
|
||||
enabled: input.enabled,
|
||||
accessKeyId: toTrimmedValue(input.accessKeyId),
|
||||
secretAccessKeyEnc: normalizedSecret
|
||||
? encrypt(normalizedSecret)
|
||||
: (existing?.secretAccessKeyEnc ?? null),
|
||||
region: toTrimmedValue(input.region),
|
||||
bucket: toTrimmedValue(input.bucket),
|
||||
endpoint: toTrimmedValue(input.endpoint) || null,
|
||||
additionalFlags: [
|
||||
...new Set(
|
||||
input.additionalFlags.map((entry) => entry.trim()).filter(Boolean),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function getDestinationById(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
id: string,
|
||||
) {
|
||||
return prismaStorage.userStorageDestination.findFirst({
|
||||
where: { id, userId, organizationId },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
organizationId: true,
|
||||
serverId: true,
|
||||
name: true,
|
||||
provider: true,
|
||||
enabled: true,
|
||||
accessKeyId: true,
|
||||
secretAccessKeyEnc: true,
|
||||
region: true,
|
||||
bucket: true,
|
||||
endpoint: true,
|
||||
additionalFlags: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
server: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ip: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function storageDestinationRoutes(app: FastifyInstance) {
|
||||
app.get("/", { preHandler: [authenticate] }, async (req, reply) => {
|
||||
const organizationId = req.organizationId;
|
||||
if (!organizationId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Active organization is required",
|
||||
});
|
||||
}
|
||||
|
||||
const destinations = await prismaStorage.userStorageDestination.findMany({
|
||||
where: {
|
||||
userId: req.userId!,
|
||||
organizationId,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
organizationId: true,
|
||||
serverId: true,
|
||||
name: true,
|
||||
provider: true,
|
||||
enabled: true,
|
||||
accessKeyId: true,
|
||||
secretAccessKeyEnc: true,
|
||||
region: true,
|
||||
bucket: true,
|
||||
endpoint: true,
|
||||
additionalFlags: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
server: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ip: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: destinations.map(serializeDestination),
|
||||
});
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/",
|
||||
{ preHandler: [requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const organizationId = req.organizationId;
|
||||
if (!organizationId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Active organization is required",
|
||||
});
|
||||
}
|
||||
|
||||
const body = StorageDestinationSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: body.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const validationError = validateDestinationInput(body.data, false);
|
||||
if (validationError) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: validationError });
|
||||
}
|
||||
|
||||
const accessibleServerId = await ensureServerAccess(
|
||||
req.userId!,
|
||||
organizationId,
|
||||
body.data.serverId,
|
||||
);
|
||||
if (accessibleServerId === false) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Selected target server is not accessible",
|
||||
});
|
||||
}
|
||||
|
||||
const created = await prismaStorage.userStorageDestination.create({
|
||||
data: {
|
||||
userId: req.userId!,
|
||||
organizationId,
|
||||
serverId: accessibleServerId,
|
||||
...buildDestinationWriteData(body.data),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
organizationId: true,
|
||||
serverId: true,
|
||||
name: true,
|
||||
provider: true,
|
||||
enabled: true,
|
||||
accessKeyId: true,
|
||||
secretAccessKeyEnc: true,
|
||||
region: true,
|
||||
bucket: true,
|
||||
endpoint: true,
|
||||
additionalFlags: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
server: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ip: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
action: "STORAGE_DESTINATION_CREATE",
|
||||
category: "SYSTEM",
|
||||
level: "INFO",
|
||||
message: `Storage destination ${created.name} created`,
|
||||
meta: {
|
||||
destinationId: created.id,
|
||||
provider: created.provider,
|
||||
serverId: created.serverId,
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: serializeDestination(created),
|
||||
message: "Storage destination created successfully",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/:id",
|
||||
{ preHandler: [requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const organizationId = req.organizationId;
|
||||
if (!organizationId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Active organization is required",
|
||||
});
|
||||
}
|
||||
|
||||
const id = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.safeParse((req.params as { id?: string }).id);
|
||||
const body = StorageDestinationSchema.safeParse(req.body);
|
||||
if (!id.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Invalid destination ID",
|
||||
});
|
||||
}
|
||||
|
||||
if (!body.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: body.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await getDestinationById(
|
||||
req.userId!,
|
||||
organizationId,
|
||||
id.data,
|
||||
);
|
||||
if (!existing) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: "Storage destination not found",
|
||||
});
|
||||
}
|
||||
|
||||
const validationError = validateDestinationInput(
|
||||
body.data,
|
||||
Boolean(existing.secretAccessKeyEnc),
|
||||
);
|
||||
if (validationError) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: validationError });
|
||||
}
|
||||
|
||||
const accessibleServerId = await ensureServerAccess(
|
||||
req.userId!,
|
||||
organizationId,
|
||||
body.data.serverId,
|
||||
);
|
||||
if (accessibleServerId === false) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Selected target server is not accessible",
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prismaStorage.userStorageDestination.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
serverId: accessibleServerId,
|
||||
...buildDestinationWriteData(body.data, existing),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
organizationId: true,
|
||||
serverId: true,
|
||||
name: true,
|
||||
provider: true,
|
||||
enabled: true,
|
||||
accessKeyId: true,
|
||||
secretAccessKeyEnc: true,
|
||||
region: true,
|
||||
bucket: true,
|
||||
endpoint: true,
|
||||
additionalFlags: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
server: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ip: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
action: "STORAGE_DESTINATION_UPDATE",
|
||||
category: "SYSTEM",
|
||||
level: "INFO",
|
||||
message: `Storage destination ${updated.name} updated`,
|
||||
meta: {
|
||||
destinationId: updated.id,
|
||||
provider: updated.provider,
|
||||
serverId: updated.serverId,
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: serializeDestination(updated),
|
||||
message: "Storage destination updated successfully",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/:id",
|
||||
{ preHandler: [requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const organizationId = req.organizationId;
|
||||
if (!organizationId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Active organization is required",
|
||||
});
|
||||
}
|
||||
|
||||
const id = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.safeParse((req.params as { id?: string }).id);
|
||||
if (!id.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Invalid destination ID",
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await getDestinationById(
|
||||
req.userId!,
|
||||
organizationId,
|
||||
id.data,
|
||||
);
|
||||
if (!existing) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: "Storage destination not found",
|
||||
});
|
||||
}
|
||||
|
||||
await prismaStorage.userStorageDestination.delete({
|
||||
where: { id: existing.id },
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
action: "STORAGE_DESTINATION_DELETE",
|
||||
category: "SYSTEM",
|
||||
level: "INFO",
|
||||
message: `Storage destination ${existing.name} deleted`,
|
||||
meta: {
|
||||
destinationId: existing.id,
|
||||
provider: existing.provider,
|
||||
serverId: existing.serverId,
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Storage destination deleted successfully",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/verify",
|
||||
{ preHandler: [requireRole("DEVELOPER")] },
|
||||
async (req, reply) => {
|
||||
const organizationId = req.organizationId;
|
||||
if (!organizationId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Active organization is required",
|
||||
});
|
||||
}
|
||||
|
||||
const body = StorageDestinationVerifySchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: body.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const existing = body.data.id
|
||||
? await getDestinationById(req.userId!, organizationId, body.data.id)
|
||||
: null;
|
||||
|
||||
const validationError = validateDestinationInput(
|
||||
body.data,
|
||||
Boolean(existing?.secretAccessKeyEnc),
|
||||
);
|
||||
if (validationError) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: validationError });
|
||||
}
|
||||
|
||||
try {
|
||||
const accessKeyId =
|
||||
toTrimmedValue(body.data.accessKeyId) || existing?.accessKeyId || "";
|
||||
const secretAccessKey =
|
||||
toTrimmedValue(body.data.secretAccessKey) ||
|
||||
(existing?.secretAccessKeyEnc
|
||||
? decrypt(existing.secretAccessKeyEnc)
|
||||
: "");
|
||||
const region =
|
||||
toTrimmedValue(body.data.region) || existing?.region || "";
|
||||
const bucket =
|
||||
toTrimmedValue(body.data.bucket) || existing?.bucket || "";
|
||||
const endpoint =
|
||||
toTrimmedValue(body.data.endpoint) || existing?.endpoint || "";
|
||||
|
||||
if (!accessKeyId || !secretAccessKey || !region || !bucket) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error:
|
||||
"Access key, secret access key, region, and bucket are required",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await verifyAwsS3Connection({
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
region,
|
||||
bucket,
|
||||
endpoint: endpoint || undefined,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: result,
|
||||
message: result.message,
|
||||
});
|
||||
} catch (error) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Verification failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { Server } from "@prisma/client";
|
||||
import { authenticate, requireApiKeyPermission } from "../middleware/auth";
|
||||
import { auditLog } from "../services/audit.service";
|
||||
import { getAccessibleServer } from "../services/server-access.service";
|
||||
import { exec, getConnection } from "../services/ssh.service";
|
||||
import {
|
||||
consumeTerminalSocketTicket,
|
||||
issueTerminalSocketTicket,
|
||||
} from "../services/terminal-ticket.service";
|
||||
|
||||
const SESSION_TTL_MS = 30 * 60 * 1000;
|
||||
const TERMINAL_EXEC_TIMEOUT_MS = 20_000;
|
||||
const terminalAccess = [
|
||||
authenticate,
|
||||
requireApiKeyPermission("write:containers"),
|
||||
];
|
||||
|
||||
type TerminalStreamHandler = (data: Buffer) => void;
|
||||
|
||||
interface TerminalShellStream {
|
||||
stderr?: {
|
||||
on(event: "data", handler: TerminalStreamHandler): void;
|
||||
off(event: "data", handler: TerminalStreamHandler): void;
|
||||
};
|
||||
on(event: "data", handler: TerminalStreamHandler): void;
|
||||
on(event: "close", handler: () => void): void;
|
||||
off(event: "data", handler: TerminalStreamHandler): void;
|
||||
off(event: "close", handler: () => void): void;
|
||||
write(data: string | undefined): void;
|
||||
setWindow(rows: number, cols: number, height: number, width: number): void;
|
||||
end?: () => void;
|
||||
close?: () => void;
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
interface TerminalSocket {
|
||||
readyState: number;
|
||||
send(data: string): void;
|
||||
close(): void;
|
||||
on(event: "message", handler: (raw: Buffer | string, isBinary: boolean) => void): void;
|
||||
on(event: "close" | "error", handler: () => void): void;
|
||||
}
|
||||
|
||||
interface TerminalSession {
|
||||
id: string;
|
||||
userId?: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
serverIp: string;
|
||||
shellStream: TerminalShellStream;
|
||||
createdAt: number;
|
||||
lastActiveAt: number;
|
||||
cols: number;
|
||||
rows: number;
|
||||
attachedSocket?: TerminalSocket;
|
||||
onData?: TerminalStreamHandler;
|
||||
onStdErr?: TerminalStreamHandler;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const terminalSessions = new Map<string, TerminalSession>();
|
||||
|
||||
function getPublicSession(session: TerminalSession) {
|
||||
return {
|
||||
id: session.id,
|
||||
serverId: session.serverId,
|
||||
serverName: session.serverName,
|
||||
serverIp: session.serverIp,
|
||||
createdAt: new Date(session.createdAt).toISOString(),
|
||||
lastActiveAt: new Date(session.lastActiveAt).toISOString(),
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
};
|
||||
}
|
||||
|
||||
function detachSocket(session: TerminalSession) {
|
||||
if (session.onData) session.shellStream.off("data", session.onData);
|
||||
if (session.onStdErr)
|
||||
session.shellStream.stderr?.off("data", session.onStdErr);
|
||||
if (session.onClose) session.shellStream.off("close", session.onClose);
|
||||
session.onData = undefined;
|
||||
session.onStdErr = undefined;
|
||||
session.onClose = undefined;
|
||||
session.attachedSocket = undefined;
|
||||
}
|
||||
|
||||
function destroySession(sessionId: string) {
|
||||
const session = terminalSessions.get(sessionId);
|
||||
if (!session) return;
|
||||
detachSocket(session);
|
||||
session.shellStream?.end?.();
|
||||
session.shellStream?.close?.();
|
||||
session.shellStream?.destroy?.();
|
||||
terminalSessions.delete(sessionId);
|
||||
}
|
||||
|
||||
function pruneExpiredSessions() {
|
||||
const now = Date.now();
|
||||
for (const [sessionId, session] of terminalSessions.entries()) {
|
||||
if (session.attachedSocket) continue;
|
||||
if (now - session.lastActiveAt > SESSION_TTL_MS) {
|
||||
destroySession(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function attachSocket(session: TerminalSession, socket: TerminalSocket) {
|
||||
detachSocket(session);
|
||||
session.attachedSocket = socket;
|
||||
session.lastActiveAt = Date.now();
|
||||
|
||||
session.onData = (data: Buffer) => {
|
||||
if (socket.readyState === 1) {
|
||||
socket.send(JSON.stringify({ type: "data", data: data.toString() }));
|
||||
}
|
||||
};
|
||||
session.onStdErr = (data: Buffer) => {
|
||||
if (socket.readyState === 1) {
|
||||
socket.send(JSON.stringify({ type: "data", data: data.toString() }));
|
||||
}
|
||||
};
|
||||
session.onClose = () => {
|
||||
if (socket.readyState === 1) {
|
||||
socket.send(JSON.stringify({ type: "exit", data: "" }));
|
||||
socket.close();
|
||||
}
|
||||
destroySession(session.id);
|
||||
};
|
||||
|
||||
session.shellStream.on("data", session.onData);
|
||||
session.shellStream.stderr?.on("data", session.onStdErr);
|
||||
session.shellStream.on("close", session.onClose);
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "ready",
|
||||
sessionId: session.id,
|
||||
data: `Connected to ${session.serverName} (${session.serverIp})\r\n`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function createTerminalSession(
|
||||
server: Server,
|
||||
userId: string | undefined,
|
||||
cols: number,
|
||||
rows: number,
|
||||
sessionId?: string,
|
||||
) {
|
||||
const ssh = await getConnection(server);
|
||||
const id = sessionId || randomUUID();
|
||||
|
||||
const shellStream = await new Promise<TerminalShellStream>(
|
||||
(resolve, reject) => {
|
||||
ssh.connection!.shell(
|
||||
{ term: "xterm-256color", cols, rows },
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(stream as TerminalShellStream);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const session: TerminalSession = {
|
||||
id,
|
||||
userId,
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
serverIp: server.ip,
|
||||
shellStream,
|
||||
createdAt: Date.now(),
|
||||
lastActiveAt: Date.now(),
|
||||
cols,
|
||||
rows,
|
||||
};
|
||||
|
||||
terminalSessions.set(id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal routes — WebSocket-based interactive SSH terminal
|
||||
* and HTTP endpoint for one-shot command execution
|
||||
*/
|
||||
export async function terminalRoutes(app: FastifyInstance) {
|
||||
app.get("/sessions", { preHandler: terminalAccess }, async (req, reply) => {
|
||||
pruneExpiredSessions();
|
||||
const sessions = [...terminalSessions.values()]
|
||||
.filter((session) => session.userId === req.userId)
|
||||
.map(getPublicSession)
|
||||
.sort((left, right) =>
|
||||
right.lastActiveAt.localeCompare(left.lastActiveAt),
|
||||
);
|
||||
|
||||
return reply.send({ success: true, data: sessions });
|
||||
});
|
||||
|
||||
app.delete(
|
||||
"/sessions/:id",
|
||||
{ preHandler: terminalAccess },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = terminalSessions.get(id);
|
||||
if (!session || session.userId !== req.userId) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Session not found" });
|
||||
}
|
||||
|
||||
destroySession(id);
|
||||
return reply.send({ success: true, message: "Session closed" });
|
||||
},
|
||||
);
|
||||
|
||||
app.post("/ws-ticket", { preHandler: terminalAccess }, async (req, reply) => {
|
||||
const body = z
|
||||
.object({
|
||||
serverId: z.string().min(1),
|
||||
sessionId: z.string().trim().min(1).max(128).optional(),
|
||||
})
|
||||
.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const { serverId, sessionId } = body.data;
|
||||
const server = await getAccessibleServer(
|
||||
req.userId,
|
||||
serverId,
|
||||
req.organizationId,
|
||||
);
|
||||
|
||||
if (!server) {
|
||||
return reply.status(403).send({
|
||||
success: false,
|
||||
error: "Forbidden — you do not have access to this server",
|
||||
});
|
||||
}
|
||||
|
||||
const ticket = issueTerminalSocketTicket({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
ticket: ticket.id,
|
||||
expiresAt: new Date(ticket.expiresAt).toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// POST /terminal/exec — run a single command, get output
|
||||
app.post("/exec", { preHandler: terminalAccess }, async (req, reply) => {
|
||||
const body = z
|
||||
.object({
|
||||
serverId: z.string(),
|
||||
command: z.string().min(1).max(2048),
|
||||
})
|
||||
.safeParse(req.body);
|
||||
if (!body.success)
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
|
||||
const { serverId, command } = body.data;
|
||||
const server = await getAccessibleServer(
|
||||
req.userId,
|
||||
serverId,
|
||||
req.organizationId,
|
||||
);
|
||||
if (!server) {
|
||||
return reply.status(403).send({
|
||||
success: false,
|
||||
error: "Forbidden — you do not have access to this server",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await exec(server, command, {
|
||||
timeoutMs: TERMINAL_EXEC_TIMEOUT_MS,
|
||||
queueTimeoutMs: TERMINAL_EXEC_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
serverId,
|
||||
action: "TERMINAL_EXEC",
|
||||
category: "TERMINAL",
|
||||
level: "INFO",
|
||||
message: `Executed: ${command.slice(0, 80)}`,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.code,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Terminal command failed",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /terminal/ws/:serverId — WebSocket interactive shell
|
||||
app.get(
|
||||
"/ws/:serverId",
|
||||
{ websocket: true },
|
||||
async (socket, req) => {
|
||||
pruneExpiredSessions();
|
||||
|
||||
const { serverId } = req.params as { serverId: string };
|
||||
const {
|
||||
ticket,
|
||||
cols = "220",
|
||||
rows = "50",
|
||||
sessionId,
|
||||
} = req.query as {
|
||||
ticket?: string;
|
||||
cols?: string;
|
||||
rows?: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
const socketTicket = consumeTerminalSocketTicket({
|
||||
ticketId: ticket,
|
||||
serverId,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!socketTicket) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
data: "Unauthorized — terminal ticket is missing, invalid, or expired",
|
||||
}),
|
||||
);
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
req.userId = socketTicket.userId;
|
||||
req.organizationId = socketTicket.organizationId;
|
||||
const server = await getAccessibleServer(
|
||||
req.userId,
|
||||
serverId,
|
||||
req.organizationId,
|
||||
);
|
||||
|
||||
if (!server) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
data: "Forbidden — you do not have access to this server",
|
||||
}),
|
||||
);
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedCols = parseInt(cols, 10) || 220;
|
||||
const parsedRows = parseInt(rows, 10) || 50;
|
||||
|
||||
let session = sessionId ? terminalSessions.get(sessionId) : undefined;
|
||||
if (session && session.userId !== req.userId) {
|
||||
session = undefined;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
session = await createTerminalSession(
|
||||
server,
|
||||
req.userId,
|
||||
parsedCols,
|
||||
parsedRows,
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
session.cols = parsedCols;
|
||||
session.rows = parsedRows;
|
||||
attachSocket(session, socket);
|
||||
|
||||
// Client → Server: forward input / resize events
|
||||
socket.on("message", (raw: Buffer | string, isBinary: boolean) => {
|
||||
const rawText = typeof raw === "string" ? raw : raw.toString("utf8");
|
||||
|
||||
if (isBinary) {
|
||||
if (session?.shellStream) {
|
||||
session.shellStream.write(rawText);
|
||||
session.lastActiveAt = Date.now();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(rawText) as {
|
||||
type: string;
|
||||
data?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
if (msg.type === "input" && session?.shellStream) {
|
||||
session.shellStream.write(msg.data);
|
||||
session.lastActiveAt = Date.now();
|
||||
} else if (msg.type === "resize" && session?.shellStream) {
|
||||
session.cols = msg.cols ?? 220;
|
||||
session.rows = msg.rows ?? 50;
|
||||
session.shellStream.setWindow(session.rows, session.cols, 0, 0);
|
||||
session.lastActiveAt = Date.now();
|
||||
} else if (msg.type === "ping") {
|
||||
socket.send(JSON.stringify({ type: "pong" }));
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed control frames instead of leaking JSON into shell.
|
||||
if (rawText.trimStart().startsWith("{")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Backward-compatible raw keypress handling.
|
||||
if (session?.shellStream) {
|
||||
session.shellStream.write(rawText);
|
||||
session.lastActiveAt = Date.now();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
if (session) {
|
||||
session.lastActiveAt = Date.now();
|
||||
detachSocket(session);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
if (session) {
|
||||
session.lastActiveAt = Date.now();
|
||||
detachSocket(session);
|
||||
}
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
serverId,
|
||||
action: "TERMINAL_OPEN",
|
||||
category: "TERMINAL",
|
||||
level: "INFO",
|
||||
message: `Interactive shell opened on "${server.name}"`,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
data: `SSH error: ${err instanceof Error ? err.message : "connection failed"}`,
|
||||
}),
|
||||
);
|
||||
socket.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import bcrypt from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
import { z } from "zod";
|
||||
import { resolvePublicAppUrl } from "../lib/public-url";
|
||||
import prisma from "../lib/prisma";
|
||||
import { requireRole } from "../middleware/auth";
|
||||
import { auditLog } from "../services/audit.service";
|
||||
|
||||
const ManageableRoleSchema = z.enum(["OPERATOR", "DEVELOPER", "VIEWER"]);
|
||||
|
||||
const ServerAccessSchema = z.object({
|
||||
allServersAccess: z.boolean().default(true),
|
||||
serverIds: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
const UserCreateSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(2),
|
||||
password: z.string().min(8),
|
||||
role: ManageableRoleSchema.default("DEVELOPER"),
|
||||
allServersAccess: z.boolean().default(true),
|
||||
serverIds: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
const UserInvitationCreateSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(2),
|
||||
role: ManageableRoleSchema.default("DEVELOPER"),
|
||||
allServersAccess: z.boolean().default(true),
|
||||
serverIds: z.array(z.string()).default([]),
|
||||
expiresInDays: z.number().int().min(1).max(30).default(7),
|
||||
});
|
||||
|
||||
function createInviteToken() {
|
||||
const raw = crypto.randomBytes(32).toString("hex");
|
||||
const hash = crypto.createHash("sha256").update(raw).digest("hex");
|
||||
return { raw, hash };
|
||||
}
|
||||
|
||||
async function resolveServerIds(serverIds: string[], organizationId: string) {
|
||||
const uniqueServerIds = [...new Set(serverIds)];
|
||||
if (uniqueServerIds.length === 0) return [];
|
||||
|
||||
const servers = await prisma.server.findMany({
|
||||
where: { id: { in: uniqueServerIds }, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (servers.length !== uniqueServerIds.length) {
|
||||
throw new Error("One or more selected servers do not exist");
|
||||
}
|
||||
|
||||
return servers.map((server) => server.id);
|
||||
}
|
||||
|
||||
export async function usersRoutes(app: FastifyInstance) {
|
||||
app.get(
|
||||
"/",
|
||||
{ preHandler: [requireRole("OPERATOR")] },
|
||||
async (req, reply) => {
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
organizationMemberships: {
|
||||
some: { organizationId: req.organizationId! },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
allServersAccess: true,
|
||||
lastLogin: true,
|
||||
createdAt: true,
|
||||
serverAssignments: {
|
||||
where: {
|
||||
server: { organizationId: req.organizationId! },
|
||||
},
|
||||
select: {
|
||||
serverId: true,
|
||||
server: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
return reply.send({ success: true, data: users });
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/invitations",
|
||||
{ preHandler: [requireRole("OPERATOR")] },
|
||||
async (req, reply) => {
|
||||
const invitations = await prisma.userInvitation.findMany({
|
||||
where: {
|
||||
organizationId: req.organizationId!,
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
allServersAccess: true,
|
||||
serverIds: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const referencedServerIds = [
|
||||
...new Set(invitations.flatMap((invitation) => invitation.serverIds)),
|
||||
];
|
||||
const referencedServers =
|
||||
referencedServerIds.length > 0
|
||||
? await prisma.server.findMany({
|
||||
where: {
|
||||
id: { in: referencedServerIds },
|
||||
organizationId: req.organizationId!,
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const serverMap = new Map(
|
||||
referencedServers.map((server) => [server.id, server]),
|
||||
);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: invitations.map((invitation) => ({
|
||||
...invitation,
|
||||
servers: invitation.serverIds
|
||||
.map((serverId) => serverMap.get(serverId))
|
||||
.filter(Boolean),
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/invitations",
|
||||
{ preHandler: [requireRole("OPERATOR")] },
|
||||
async (req, reply) => {
|
||||
const body = UserInvitationCreateSchema.safeParse(req.body);
|
||||
if (!body.success)
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: body.data.email },
|
||||
});
|
||||
if (existingUser) {
|
||||
return reply
|
||||
.status(409)
|
||||
.send({ success: false, error: "Email already in use" });
|
||||
}
|
||||
|
||||
let validServerIds: string[] = [];
|
||||
try {
|
||||
validServerIds = await resolveServerIds(
|
||||
body.data.serverIds,
|
||||
req.organizationId!,
|
||||
);
|
||||
} catch (error) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Invalid server selection",
|
||||
});
|
||||
}
|
||||
|
||||
const { raw, hash } = createInviteToken();
|
||||
const expiresAt = new Date(
|
||||
Date.now() + body.data.expiresInDays * 86400000,
|
||||
);
|
||||
|
||||
const invitation = await prisma.$transaction(async (tx) => {
|
||||
await tx.userInvitation.updateMany({
|
||||
where: {
|
||||
email: body.data.email,
|
||||
organizationId: req.organizationId!,
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
return tx.userInvitation.create({
|
||||
data: {
|
||||
organizationId: req.organizationId!,
|
||||
email: body.data.email,
|
||||
name: body.data.name,
|
||||
role: body.data.role,
|
||||
allServersAccess: body.data.allServersAccess,
|
||||
serverIds: body.data.allServersAccess ? [] : validServerIds,
|
||||
invitedById: req.userId,
|
||||
tokenHash: hash,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
action: "USER_INVITE_CREATE",
|
||||
category: "AUTH",
|
||||
level: "INFO",
|
||||
message: `Invitation created for "${invitation.email}" with role ${invitation.role}`,
|
||||
meta: { invitationId: invitation.id },
|
||||
});
|
||||
|
||||
return reply.status(201).send({
|
||||
success: true,
|
||||
data: {
|
||||
id: invitation.id,
|
||||
email: invitation.email,
|
||||
name: invitation.name,
|
||||
role: invitation.role,
|
||||
allServersAccess: invitation.allServersAccess,
|
||||
serverIds: invitation.serverIds,
|
||||
expiresAt: invitation.expiresAt,
|
||||
inviteUrl: resolvePublicAppUrl(`/invite/${raw}`, {
|
||||
headers: req.headers,
|
||||
protocol: req.protocol,
|
||||
}),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/invitations/:id/regenerate",
|
||||
{ preHandler: [requireRole("OPERATOR")] },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const invitation = await prisma.userInvitation.findFirst({
|
||||
where: {
|
||||
id,
|
||||
organizationId: req.organizationId!,
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
if (!invitation) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Invitation not found" });
|
||||
}
|
||||
|
||||
const { raw, hash } = createInviteToken();
|
||||
const expiresAt = new Date(Date.now() + 7 * 86400000);
|
||||
|
||||
await prisma.userInvitation.update({
|
||||
where: { id },
|
||||
data: { tokenHash: hash, expiresAt },
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
action: "USER_INVITE_REGENERATE",
|
||||
category: "AUTH",
|
||||
level: "INFO",
|
||||
message: `Invitation link regenerated for "${invitation.email}"`,
|
||||
meta: { invitationId: id },
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
inviteUrl: resolvePublicAppUrl(`/invite/${raw}`, {
|
||||
headers: req.headers,
|
||||
protocol: req.protocol,
|
||||
}),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/invitations/:id",
|
||||
{ preHandler: [requireRole("OPERATOR")] },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const invitation = await prisma.userInvitation.findFirst({
|
||||
where: {
|
||||
id,
|
||||
organizationId: req.organizationId!,
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
if (!invitation) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "Invitation not found" });
|
||||
}
|
||||
|
||||
await prisma.userInvitation.update({
|
||||
where: { id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
action: "USER_INVITE_REVOKE",
|
||||
category: "AUTH",
|
||||
level: "WARNING",
|
||||
message: `Invitation revoked for "${invitation.email}"`,
|
||||
meta: { invitationId: id },
|
||||
});
|
||||
|
||||
return reply.send({ success: true, message: "Invitation revoked" });
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/",
|
||||
{ preHandler: [requireRole("OPERATOR")] },
|
||||
async (req, reply) => {
|
||||
const body = UserCreateSchema.safeParse(req.body);
|
||||
if (!body.success)
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { email: body.data.email },
|
||||
});
|
||||
if (existing)
|
||||
return reply
|
||||
.status(409)
|
||||
.send({ success: false, error: "Email already in use" });
|
||||
|
||||
let validServerIds: string[] = [];
|
||||
try {
|
||||
validServerIds = await resolveServerIds(
|
||||
body.data.serverIds,
|
||||
req.organizationId!,
|
||||
);
|
||||
} catch (error) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Invalid server selection",
|
||||
});
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.data.password, 12);
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
await tx.userInvitation.updateMany({
|
||||
where: {
|
||||
email: body.data.email,
|
||||
organizationId: req.organizationId!,
|
||||
acceptedAt: null,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
return tx.user.create({
|
||||
data: {
|
||||
email: body.data.email,
|
||||
name: body.data.name,
|
||||
passwordHash,
|
||||
role: body.data.role,
|
||||
activeOrganizationId: req.organizationId!,
|
||||
allServersAccess: body.data.allServersAccess,
|
||||
organizationMemberships: {
|
||||
create: {
|
||||
organizationId: req.organizationId!,
|
||||
isDefault: true,
|
||||
},
|
||||
},
|
||||
serverAssignments:
|
||||
body.data.allServersAccess || validServerIds.length === 0
|
||||
? undefined
|
||||
: {
|
||||
create: validServerIds.map((serverId) => ({ serverId })),
|
||||
},
|
||||
},
|
||||
select: { id: true, name: true, email: true, role: true },
|
||||
});
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
action: "USER_CREATE",
|
||||
category: "AUTH",
|
||||
level: "INFO",
|
||||
message: `User "${user.email}" created with role ${user.role}`,
|
||||
});
|
||||
|
||||
return reply.status(201).send({ success: true, data: user });
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/:id/server-access",
|
||||
{ preHandler: [requireRole("OPERATOR")] },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = ServerAccessSchema.safeParse(req.body);
|
||||
if (!body.success)
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, role: true, email: true },
|
||||
});
|
||||
if (!targetUser) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "User not found" });
|
||||
}
|
||||
if (targetUser.role === "SUPER_ADMIN" && req.userRole !== "SUPER_ADMIN") {
|
||||
return reply.status(403).send({
|
||||
success: false,
|
||||
error: "Only Super Admin can modify another Super Admin",
|
||||
});
|
||||
}
|
||||
|
||||
const isOrganizationMember =
|
||||
await prisma.organizationMembership.findUnique({
|
||||
where: {
|
||||
organizationId_userId: {
|
||||
organizationId: req.organizationId!,
|
||||
userId: id,
|
||||
},
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!isOrganizationMember) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "User not found" });
|
||||
}
|
||||
|
||||
let validServerIds: string[] = [];
|
||||
try {
|
||||
validServerIds = await resolveServerIds(
|
||||
body.data.serverIds,
|
||||
req.organizationId!,
|
||||
);
|
||||
} catch (error) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Invalid server selection",
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
await tx.userServerAccess.deleteMany({ where: { userId: id } });
|
||||
|
||||
const allServersAccess =
|
||||
targetUser.role === "SUPER_ADMIN" ? true : body.data.allServersAccess;
|
||||
return tx.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
allServersAccess,
|
||||
serverAssignments:
|
||||
allServersAccess || validServerIds.length === 0
|
||||
? undefined
|
||||
: {
|
||||
create: validServerIds.map((serverId) => ({ serverId })),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
allServersAccess: true,
|
||||
serverAssignments: {
|
||||
select: {
|
||||
serverId: true,
|
||||
server: { select: { id: true, name: true } },
|
||||
},
|
||||
where: {
|
||||
server: { organizationId: req.organizationId! },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
action: "USER_SERVER_ACCESS_UPDATE",
|
||||
category: "AUTH",
|
||||
level: "INFO",
|
||||
message: `Server access updated for user "${targetUser.email}"`,
|
||||
meta: {
|
||||
allServersAccess: updated.allServersAccess,
|
||||
serverIds: updated.serverAssignments.map(
|
||||
(assignment) => assignment.serverId,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return reply.send({ success: true, data: updated });
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/:id",
|
||||
{ preHandler: [requireRole("SUPER_ADMIN")] },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
if (id === req.userId)
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: "Cannot delete your own account" });
|
||||
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, email: true, role: true },
|
||||
});
|
||||
if (!targetUser) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "User not found" });
|
||||
}
|
||||
|
||||
const isOrganizationMember =
|
||||
await prisma.organizationMembership.findUnique({
|
||||
where: {
|
||||
organizationId_userId: {
|
||||
organizationId: req.organizationId!,
|
||||
userId: id,
|
||||
},
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!isOrganizationMember) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "User not found" });
|
||||
}
|
||||
|
||||
if (targetUser.role === "SUPER_ADMIN") {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Super Admin accounts cannot be deleted",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.auditLog.updateMany({
|
||||
where: { userId: id },
|
||||
data: { userId: null },
|
||||
});
|
||||
|
||||
await tx.user.delete({ where: { id } });
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
action: "USER_DELETE",
|
||||
category: "AUTH",
|
||||
level: "WARNING",
|
||||
message: `User "${targetUser.email}" deleted`,
|
||||
meta: { deletedUserId: id },
|
||||
});
|
||||
|
||||
return reply.send({ success: true, message: "User deleted" });
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/:id/role",
|
||||
{ preHandler: [requireRole("SUPER_ADMIN")] },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = z.object({ role: ManageableRoleSchema }).safeParse(req.body);
|
||||
if (!body.success)
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
|
||||
const membership = await prisma.organizationMembership.findUnique({
|
||||
where: {
|
||||
organizationId_userId: {
|
||||
organizationId: req.organizationId!,
|
||||
userId: id,
|
||||
},
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ success: false, error: "User not found" });
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id },
|
||||
data: { role: body.data.role },
|
||||
select: { id: true, name: true, role: true },
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
action: "USER_ROLE_CHANGE",
|
||||
category: "AUTH",
|
||||
level: "INFO",
|
||||
message: `User "${user.name}" role changed to ${body.data.role}`,
|
||||
});
|
||||
|
||||
return reply.send({ success: true, data: user });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Backward-compatibility facade.
|
||||
//
|
||||
// Business logic app catalog sudah dipindahkan ke folder `./app-catalog/*`.
|
||||
// File asli sebelum modularisasi disimpan di `./backup/app-catalog.service.ts`.
|
||||
// Semua export publik tetap dipertahankan lewat re-export di bawah ini.
|
||||
|
||||
export * from "./app-catalog";
|
||||
@@ -0,0 +1,23 @@
|
||||
# App Catalog Modules
|
||||
|
||||
Folder ini berisi modularisasi business logic untuk `app-catalog.service.ts`.
|
||||
|
||||
Struktur:
|
||||
|
||||
- `types.ts`: kontrak publik dan type cache.
|
||||
- `catalog-options.ts`: opsi sumber katalog yang diexpose ke route.
|
||||
- `cache.ts`: cache remote catalog dan helper result cache.
|
||||
- `utils.ts`: helper generik untuk parsing value dan structured text.
|
||||
- `normalize.ts`: normalisasi field template generik.
|
||||
- `manifest-parsers.ts`: parser manifest CasaOS, Umbrel, dan generic manifest.
|
||||
- `remote.ts`: normalisasi URL remote, deteksi ZIP response, dan fetch wrapper.
|
||||
- `archive.ts`: scan archive ZIP dan ekstraksi katalog.
|
||||
- `service.ts`: orchestration utama `getCatalogTemplates()`.
|
||||
- `index.ts`: public barrel untuk API modul ini.
|
||||
|
||||
Pattern:
|
||||
|
||||
- Helper generik disimpan di file kecil sesuai concern.
|
||||
- `service.ts` hanya mengorkestrasi alur, tidak memuat detail parsing besar.
|
||||
- Hanya `index.ts` yang menjadi surface API publik modul baru.
|
||||
- `../app-catalog.service.ts` dipertahankan sebagai compatibility facade untuk callsite lama.
|
||||
@@ -0,0 +1,214 @@
|
||||
import AdmZip from "adm-zip";
|
||||
import { AppTemplate } from "../../config/app-templates";
|
||||
import { CatalogSourceMode, CatalogSyncResult } from "./types";
|
||||
import { dedupeTemplates, normalizePorts } from "./normalize";
|
||||
import {
|
||||
normalizeSingleManifest,
|
||||
parseCasaOsCompose,
|
||||
parseCasaOsConfigJson,
|
||||
} from "./manifest-parsers";
|
||||
import { asRecord, parseStructuredText, slugify, toStringArray } from "./utils";
|
||||
|
||||
function getAppDirKey(entryName: string): string | undefined {
|
||||
const match = entryName.match(/(^|\/)Apps\/([^/]+)\//i);
|
||||
return match?.[2]?.toLowerCase();
|
||||
}
|
||||
|
||||
function getGithubRawArchiveBase(
|
||||
archiveUrl: string,
|
||||
): { owner: string; repo: string; ref: string } | undefined {
|
||||
try {
|
||||
const parsed = new URL(archiveUrl);
|
||||
|
||||
if (parsed.hostname === "codeload.github.com") {
|
||||
const match = parsed.pathname.match(
|
||||
/^\/([^/]+)\/([^/]+)\/zip\/refs\/(?:heads|tags)\/(.+)$/i,
|
||||
);
|
||||
|
||||
if (match) {
|
||||
return { owner: match[1], repo: match[2], ref: match[3] };
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.hostname === "github.com") {
|
||||
const refMatch = parsed.pathname.match(
|
||||
/^\/([^/]+)\/([^/]+)\/archive\/refs\/(?:heads|tags)\/(.+)\.zip$/i,
|
||||
);
|
||||
|
||||
if (refMatch) {
|
||||
return { owner: refMatch[1], repo: refMatch[2], ref: refMatch[3] };
|
||||
}
|
||||
|
||||
const commitMatch = parsed.pathname.match(
|
||||
/^\/([^/]+)\/([^/]+)\/archive\/([a-f0-9]{7,40})\.zip$/i,
|
||||
);
|
||||
|
||||
if (commitMatch) {
|
||||
return {
|
||||
owner: commitMatch[1],
|
||||
repo: commitMatch[2],
|
||||
ref: commitMatch[3],
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getArchiveEntryBaseUrl(
|
||||
archiveUrl: string,
|
||||
entryName: string,
|
||||
): string | undefined {
|
||||
const github = getGithubRawArchiveBase(archiveUrl);
|
||||
if (!github) return undefined;
|
||||
|
||||
const entryPath = entryName.split("/").filter(Boolean).slice(1).join("/");
|
||||
if (!entryPath) return undefined;
|
||||
|
||||
return `https://raw.githubusercontent.com/${github.owner}/${github.repo}/${github.ref}/${entryPath}`;
|
||||
}
|
||||
|
||||
export function parseCasaOsArchiveCatalog(
|
||||
zip: AdmZip,
|
||||
archiveUrl = "",
|
||||
): AppTemplate[] {
|
||||
const configEntries = new Map<string, string>();
|
||||
const composeEntries = new Map<string, string>();
|
||||
const results: AppTemplate[] = [];
|
||||
|
||||
zip.getEntries().forEach((entry) => {
|
||||
if (entry.isDirectory) return;
|
||||
|
||||
const appDirKey = getAppDirKey(entry.entryName);
|
||||
if (!appDirKey) return;
|
||||
|
||||
const lower = entry.entryName.toLowerCase();
|
||||
if (lower.endsWith("/config.json")) {
|
||||
configEntries.set(appDirKey, entry.entryName);
|
||||
}
|
||||
|
||||
if (
|
||||
lower.endsWith("/docker-compose.yml") ||
|
||||
lower.endsWith("/docker-compose.yaml")
|
||||
) {
|
||||
composeEntries.set(appDirKey, entry.entryName);
|
||||
}
|
||||
});
|
||||
|
||||
Array.from(
|
||||
new Set([...configEntries.keys(), ...composeEntries.keys()]),
|
||||
).forEach((appDirKey) => {
|
||||
const configPath = configEntries.get(appDirKey);
|
||||
const composePath = composeEntries.get(appDirKey);
|
||||
|
||||
try {
|
||||
const configPayload = configPath
|
||||
? JSON.parse(zip.readAsText(configPath))
|
||||
: undefined;
|
||||
const composePayload = composePath
|
||||
? parseStructuredText(zip.readAsText(composePath))
|
||||
: undefined;
|
||||
|
||||
let template: AppTemplate | null = null;
|
||||
|
||||
if (composePayload) {
|
||||
template = parseCasaOsCompose(
|
||||
asRecord(composePayload) ?? {},
|
||||
appDirKey,
|
||||
composePath
|
||||
? getArchiveEntryBaseUrl(archiveUrl, composePath)
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
if (!template && configPayload) {
|
||||
template = parseCasaOsConfigJson(
|
||||
asRecord(configPayload) ?? {},
|
||||
appDirKey,
|
||||
configPath
|
||||
? getArchiveEntryBaseUrl(archiveUrl, configPath)
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
if (template) {
|
||||
if (configPayload && !template.defaultPort) {
|
||||
template.defaultPort = normalizePorts(asRecord(configPayload)?.port);
|
||||
}
|
||||
if (configPayload && template.tags.length === 0) {
|
||||
template.tags = toStringArray(asRecord(configPayload)?.categories);
|
||||
}
|
||||
results.push(template);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed app folder data.
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function parseGenericArchiveCatalog(
|
||||
zip: AdmZip,
|
||||
archiveUrl = "",
|
||||
): AppTemplate[] {
|
||||
const templates: AppTemplate[] = [];
|
||||
const candidateEntries = zip
|
||||
.getEntries()
|
||||
.filter((entry) => {
|
||||
if (entry.isDirectory) return false;
|
||||
const lower = entry.entryName.toLowerCase();
|
||||
if (!/\.(json|ya?ml)$/.test(lower)) return false;
|
||||
return /(app|catalog|manifest|template|store|config|compose|umbrel)/.test(
|
||||
lower,
|
||||
);
|
||||
})
|
||||
.slice(0, 300);
|
||||
|
||||
candidateEntries.forEach((entry) => {
|
||||
try {
|
||||
const payload = parseStructuredText(entry.getData().toString("utf8"));
|
||||
const fallbackId = slugify(
|
||||
entry.entryName.split("/").slice(-2, -1)[0] ?? entry.name,
|
||||
);
|
||||
templates.push(
|
||||
...normalizeSingleManifest(
|
||||
payload,
|
||||
fallbackId,
|
||||
getArchiveEntryBaseUrl(archiveUrl, entry.entryName),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Ignore malformed files.
|
||||
}
|
||||
});
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
export async function parseArchiveResponse(
|
||||
response: Response,
|
||||
source: CatalogSourceMode,
|
||||
label: string,
|
||||
url: string,
|
||||
): Promise<CatalogSyncResult> {
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const zip = new AdmZip(buffer);
|
||||
const casaOsTemplates = parseCasaOsArchiveCatalog(zip, url);
|
||||
const genericTemplates = parseGenericArchiveCatalog(zip, url);
|
||||
|
||||
return {
|
||||
templates: dedupeTemplates([...casaOsTemplates, ...genericTemplates]),
|
||||
meta: {
|
||||
source,
|
||||
label,
|
||||
url,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
isRemote: true,
|
||||
format: casaOsTemplates.length > 0 ? "casaos-archive" : "archive",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AppTemplate } from "../../config/app-templates";
|
||||
import { CacheEntry, CatalogMeta, CatalogSyncResult } from "./types";
|
||||
|
||||
const REMOTE_CATALOG_CACHE = new Map<string, CacheEntry>();
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export function getCacheKey(source: string, url?: string): string {
|
||||
return `${source}:${(url ?? "").trim()}`;
|
||||
}
|
||||
|
||||
export function getCachedCatalog(key: string): CacheEntry | undefined {
|
||||
const entry = REMOTE_CATALOG_CACHE.get(key);
|
||||
if (!entry) return undefined;
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
REMOTE_CATALOG_CACHE.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function setCachedCatalog(
|
||||
key: string,
|
||||
templates: AppTemplate[],
|
||||
meta: CatalogMeta,
|
||||
): CatalogSyncResult {
|
||||
const expiresAt = Date.now() + CACHE_TTL_MS;
|
||||
const cacheMeta: CatalogMeta = {
|
||||
...meta,
|
||||
cached: false,
|
||||
expiresAt: new Date(expiresAt).toISOString(),
|
||||
};
|
||||
|
||||
REMOTE_CATALOG_CACHE.set(key, {
|
||||
templates,
|
||||
meta: cacheMeta,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return { templates, meta: cacheMeta };
|
||||
}
|
||||
|
||||
export function makeCachedResult(entry: CacheEntry): CatalogSyncResult {
|
||||
return {
|
||||
templates: entry.templates,
|
||||
meta: {
|
||||
...entry.meta,
|
||||
cached: true,
|
||||
expiresAt: new Date(entry.expiresAt).toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { CatalogSourceOption } from "./types";
|
||||
|
||||
export const CATALOG_SOURCE_OPTIONS: CatalogSourceOption[] = [
|
||||
{
|
||||
id: "local",
|
||||
label: "Built-in Catalog",
|
||||
description: "Use the local catalog bundled with this backend.",
|
||||
requiresUrl: false,
|
||||
},
|
||||
{
|
||||
id: "auto-detect",
|
||||
label: "Custom Source",
|
||||
description:
|
||||
"Paste a remote URL and let the backend detect whether it is a manifest, compose/config file, or ZIP app store archive.",
|
||||
requiresUrl: true,
|
||||
placeholder:
|
||||
"https://github.com/bigbeartechworld/big-bear-casaos/archive/refs/heads/master.zip",
|
||||
exampleUrl: "https://casaos-appstore.paodayag.dev/linuxserver.zip",
|
||||
},
|
||||
{
|
||||
id: "manifest-url",
|
||||
label: "Manifest URL",
|
||||
description:
|
||||
"Fetch app entries from a remote JSON, YAML, CasaOS config, compose, or Umbrel manifest URL.",
|
||||
requiresUrl: true,
|
||||
placeholder: "https://example.com/apps.json",
|
||||
exampleUrl:
|
||||
"https://raw.githubusercontent.com/bigbeartechworld/big-bear-casaos/master/Apps/2fauth/config.json",
|
||||
},
|
||||
{
|
||||
id: "github-archive",
|
||||
label: "GitHub Archive ZIP",
|
||||
description:
|
||||
"Download a GitHub archive ZIP and scan CasaOS app folders, config.json, docker-compose.yml, JSON, or YAML manifests.",
|
||||
requiresUrl: true,
|
||||
placeholder:
|
||||
"https://github.com/bigbeartechworld/big-bear-casaos/archive/refs/heads/master.zip",
|
||||
exampleUrl:
|
||||
"https://github.com/bigbeartechworld/big-bear-casaos/archive/refs/heads/master.zip",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./types";
|
||||
export * from "./catalog-options";
|
||||
export * from "./service";
|
||||
@@ -0,0 +1,207 @@
|
||||
import { AppTemplate } from "../../config/app-templates";
|
||||
import {
|
||||
asNumber,
|
||||
asRecord,
|
||||
asString,
|
||||
buildImageWithVersion,
|
||||
slugify,
|
||||
toStringArray,
|
||||
} from "./utils";
|
||||
import {
|
||||
extractCandidateEntries,
|
||||
normalizeEntry,
|
||||
normalizeEnv,
|
||||
normalizeIcon,
|
||||
normalizePorts,
|
||||
normalizeVolumes,
|
||||
} from "./normalize";
|
||||
|
||||
function getMainService(
|
||||
composeRecord: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
const services = asRecord(composeRecord.services);
|
||||
if (!services) return undefined;
|
||||
|
||||
const casaOs = asRecord(composeRecord["x-casaos"]);
|
||||
const mainName = asString(casaOs?.main);
|
||||
if (mainName) {
|
||||
const service = asRecord(services[mainName]);
|
||||
if (service) return service;
|
||||
}
|
||||
|
||||
const firstKey = Object.keys(services)[0];
|
||||
return firstKey ? asRecord(services[firstKey]) : undefined;
|
||||
}
|
||||
|
||||
function isGenericAppTemplateRecord(record: Record<string, unknown>): boolean {
|
||||
return [
|
||||
"desc",
|
||||
"category",
|
||||
"tags",
|
||||
"defaultPort",
|
||||
"defaultEnv",
|
||||
"defaultVolumes",
|
||||
"defaultCommand",
|
||||
"defaultNetwork",
|
||||
"restartPolicy",
|
||||
"presets",
|
||||
"presentation",
|
||||
].some((key) => key in record);
|
||||
}
|
||||
|
||||
export function parseCasaOsCompose(
|
||||
composeRecord: Record<string, unknown>,
|
||||
fallbackId: string,
|
||||
baseUrl?: string,
|
||||
): AppTemplate | null {
|
||||
const casaOs = asRecord(composeRecord["x-casaos"]);
|
||||
const mainService = getMainService(composeRecord);
|
||||
const titleBlock = asRecord(casaOs?.title);
|
||||
const descBlock = asRecord(casaOs?.description);
|
||||
const taglineBlock = asRecord(casaOs?.tagline);
|
||||
|
||||
const name =
|
||||
asString(titleBlock?.en_us) ??
|
||||
asString(casaOs?.title) ??
|
||||
asString(casaOs?.name) ??
|
||||
fallbackId;
|
||||
const description =
|
||||
asString(descBlock?.en_us) ??
|
||||
asString(taglineBlock?.en_us) ??
|
||||
asString(casaOs?.description) ??
|
||||
"Community app compose manifest";
|
||||
const image =
|
||||
asString(mainService?.image) ??
|
||||
buildImageWithVersion(asString(casaOs?.image), asString(casaOs?.version));
|
||||
|
||||
if (!image) return null;
|
||||
|
||||
return {
|
||||
id: asString(casaOs?.id) ?? slugify(fallbackId),
|
||||
name,
|
||||
desc: description,
|
||||
category: asString(casaOs?.category) ?? "Community",
|
||||
icon: normalizeIcon({ icon: casaOs?.icon, logo: casaOs?.logo }, baseUrl),
|
||||
image,
|
||||
defaultPort: normalizePorts(casaOs?.port_map ?? mainService?.ports),
|
||||
defaultEnv: normalizeEnv(mainService?.environment),
|
||||
defaultVolumes: normalizeVolumes(mainService?.volumes) || undefined,
|
||||
defaultCommand: asString(mainService?.command),
|
||||
defaultNetwork:
|
||||
asString(mainService?.network_mode) ?? asString(casaOs?.network),
|
||||
restartPolicy:
|
||||
asString(mainService?.restart) ??
|
||||
asString(casaOs?.restart_policy) ??
|
||||
"unless-stopped",
|
||||
popular: false,
|
||||
tags: [
|
||||
...new Set(
|
||||
[
|
||||
asString(casaOs?.category),
|
||||
"community",
|
||||
...toStringArray(casaOs?.architectures),
|
||||
].filter((tag): tag is string => Boolean(tag)),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCasaOsConfigJson(
|
||||
record: Record<string, unknown>,
|
||||
fallbackId: string,
|
||||
baseUrl?: string,
|
||||
): AppTemplate | null {
|
||||
const id = asString(record.id) ?? slugify(fallbackId);
|
||||
const name =
|
||||
asString(record.name) ??
|
||||
asString(asRecord(record.title)?.en_us) ??
|
||||
asString(record.id) ??
|
||||
fallbackId;
|
||||
const image = buildImageWithVersion(
|
||||
asString(record.image),
|
||||
asString(record.version),
|
||||
);
|
||||
|
||||
if (!image) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
icon: normalizeIcon(record, baseUrl),
|
||||
desc:
|
||||
asString(record.description) ??
|
||||
asString(record.short_desc) ??
|
||||
"Community app config",
|
||||
category: toStringArray(record.categories)[0] ?? "Community",
|
||||
image,
|
||||
defaultPort: normalizePorts(record.port),
|
||||
defaultEnv: normalizeEnv(record.form_fields),
|
||||
restartPolicy: "unless-stopped",
|
||||
popular: Boolean(record.featured ?? false),
|
||||
tags: [...new Set([...toStringArray(record.categories), "community"])],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUmbrelManifest(
|
||||
record: Record<string, unknown>,
|
||||
fallbackId: string,
|
||||
baseUrl?: string,
|
||||
): AppTemplate | null {
|
||||
const name = asString(record.name) ?? fallbackId;
|
||||
const id = asString(record.id) ?? slugify(fallbackId);
|
||||
const port = asNumber(record.port);
|
||||
const image =
|
||||
asString(record.image) ??
|
||||
buildImageWithVersion(asString(record.repo), asString(record.version));
|
||||
|
||||
if (!name || !image) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
icon: normalizeIcon(record, baseUrl),
|
||||
desc:
|
||||
asString(record.description) ??
|
||||
asString(record.tagline) ??
|
||||
"Umbrel manifest",
|
||||
category: asString(record.category) ?? "Umbrel",
|
||||
image,
|
||||
defaultPort: port ? `${port}:${port}` : normalizePorts(record.port),
|
||||
defaultEnv: "",
|
||||
defaultVolumes: undefined,
|
||||
defaultCommand: undefined,
|
||||
defaultNetwork: undefined,
|
||||
restartPolicy: "unless-stopped",
|
||||
popular: false,
|
||||
tags: [
|
||||
...new Set(["umbrel", asString(record.category) ?? ""].filter(Boolean)),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSingleManifest(
|
||||
payload: unknown,
|
||||
fallbackId: string,
|
||||
baseUrl?: string,
|
||||
): AppTemplate[] {
|
||||
const record = asRecord(payload);
|
||||
if (!record) return [];
|
||||
|
||||
const compose = parseCasaOsCompose(record, fallbackId, baseUrl);
|
||||
if (compose) return [compose];
|
||||
|
||||
if (isGenericAppTemplateRecord(record)) {
|
||||
const generic = normalizeEntry(record, 0, baseUrl);
|
||||
if (generic) return [generic];
|
||||
}
|
||||
|
||||
const casaConfig = parseCasaOsConfigJson(record, fallbackId, baseUrl);
|
||||
if (casaConfig) return [casaConfig];
|
||||
|
||||
const umbrel = parseUmbrelManifest(record, fallbackId, baseUrl);
|
||||
if (umbrel) return [umbrel];
|
||||
|
||||
return extractCandidateEntries(payload)
|
||||
.map((entry, index) => normalizeEntry(entry, index, baseUrl))
|
||||
.filter((entry): entry is AppTemplate => Boolean(entry));
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { AppTemplate } from "../../config/app-templates";
|
||||
import {
|
||||
asNumber,
|
||||
asRecord,
|
||||
asString,
|
||||
buildImageWithVersion,
|
||||
slugify,
|
||||
toStringArray,
|
||||
} from "./utils";
|
||||
|
||||
function isAbsoluteHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value.trim());
|
||||
}
|
||||
|
||||
function resolveRemoteAssetUrl(
|
||||
value: unknown,
|
||||
baseUrl?: string,
|
||||
): string | undefined {
|
||||
const raw = asString(value);
|
||||
if (!raw) return undefined;
|
||||
if (isAbsoluteHttpUrl(raw)) return raw;
|
||||
if (!baseUrl) return undefined;
|
||||
|
||||
try {
|
||||
return new URL(raw, baseUrl).toString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeTags(entry: Record<string, unknown>): string[] {
|
||||
const explicit = toStringArray(
|
||||
entry.tags ?? entry.keywords ?? entry.categories ?? entry.category,
|
||||
);
|
||||
return explicit.slice(0, 8);
|
||||
}
|
||||
|
||||
export function normalizePorts(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return `${value}:${value}`;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item.trim();
|
||||
if (typeof item === "number") return `${item}:${item}`;
|
||||
const record = asRecord(item);
|
||||
if (!record) return "";
|
||||
|
||||
const host = String(
|
||||
record.host ?? record.hostPort ?? record.public ?? record.port ?? "",
|
||||
).trim();
|
||||
const container = String(
|
||||
record.container ??
|
||||
record.containerPort ??
|
||||
record.private ??
|
||||
record.target ??
|
||||
record.port ??
|
||||
"",
|
||||
).trim();
|
||||
const protocol = String(record.protocol ?? "tcp")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (!host || !container) return "";
|
||||
return protocol === "udp"
|
||||
? `${host}:${container}/udp`
|
||||
: `${host}:${container}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
if (!record) return "";
|
||||
|
||||
return Object.entries(record)
|
||||
.map(([container, host]) => {
|
||||
const hostStr = String(host ?? "").trim();
|
||||
const containerStr = container.trim();
|
||||
return hostStr && containerStr ? `${hostStr}:${containerStr}` : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
export function normalizeEnv(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item.trim();
|
||||
const record = asRecord(item);
|
||||
if (!record) return "";
|
||||
|
||||
const key = String(
|
||||
record.key ?? record.name ?? record.label ?? "",
|
||||
).trim();
|
||||
const val = String(
|
||||
record.value ?? record.default ?? record.defaultValue ?? "",
|
||||
).trim();
|
||||
return key ? `${key}=${val}` : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
if (!record) return "";
|
||||
|
||||
return Object.entries(record)
|
||||
.map(([key, val]) => `${key}=${String(val ?? "").trim()}`)
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function normalizeVolumes(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item.trim();
|
||||
const record = asRecord(item);
|
||||
if (!record) return "";
|
||||
|
||||
const host = String(
|
||||
record.host ?? record.source ?? record.src ?? record.bind ?? "",
|
||||
).trim();
|
||||
const container = String(
|
||||
record.container ??
|
||||
record.target ??
|
||||
record.dst ??
|
||||
record.destination ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
return host && container ? `${host}:${container}` : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
if (!record) return "";
|
||||
|
||||
return Object.entries(record)
|
||||
.map(([container, host]) => {
|
||||
const hostStr = String(host ?? "").trim();
|
||||
return hostStr && container.trim()
|
||||
? `${hostStr}:${container.trim()}`
|
||||
: "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function normalizePresetList(entry: Record<string, unknown>) {
|
||||
const presetsRaw = Array.isArray(entry.presets) ? entry.presets : [];
|
||||
|
||||
return presetsRaw
|
||||
.map((preset) => {
|
||||
const record = asRecord(preset);
|
||||
if (!record) return null;
|
||||
|
||||
const name =
|
||||
asString(record.name) ??
|
||||
asString(record.title) ??
|
||||
asString(record.id) ??
|
||||
"Preset";
|
||||
const id = asString(record.id) ?? slugify(name);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
desc: asString(record.desc) ?? asString(record.description) ?? "",
|
||||
defaultPort: normalizePorts(
|
||||
record.defaultPort ?? record.ports ?? record.port_map,
|
||||
),
|
||||
defaultEnv: normalizeEnv(
|
||||
record.defaultEnv ?? record.env ?? record.environment,
|
||||
),
|
||||
defaultVolumes: normalizeVolumes(
|
||||
record.defaultVolumes ?? record.volumes ?? record.storage,
|
||||
),
|
||||
defaultCommand:
|
||||
asString(record.defaultCommand) ?? asString(record.command),
|
||||
defaultNetwork:
|
||||
asString(record.defaultNetwork) ?? asString(record.network),
|
||||
restartPolicy:
|
||||
asString(record.restartPolicy) ?? asString(record.restart),
|
||||
};
|
||||
})
|
||||
.filter((preset): preset is NonNullable<typeof preset> => Boolean(preset));
|
||||
}
|
||||
|
||||
function normalizePresentation(
|
||||
entry: Record<string, unknown>,
|
||||
baseUrl?: string,
|
||||
): AppTemplate["presentation"] | undefined {
|
||||
const presentation = asRecord(entry.presentation);
|
||||
if (!presentation) return undefined;
|
||||
|
||||
const icon = resolveRemoteAssetUrl(presentation.icon, baseUrl);
|
||||
const color = asString(presentation.color);
|
||||
const installs = asNumber(presentation.installs);
|
||||
|
||||
if (!icon && !color && installs === undefined) return undefined;
|
||||
|
||||
return {
|
||||
...(icon ? { icon } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(installs !== undefined ? { installs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeIcon(
|
||||
entry: Record<string, unknown>,
|
||||
baseUrl?: string,
|
||||
): string | undefined {
|
||||
const iconBlock = asRecord(entry.icon);
|
||||
const logoBlock = asRecord(entry.logo);
|
||||
|
||||
return (
|
||||
resolveRemoteAssetUrl(entry.icon, baseUrl) ??
|
||||
resolveRemoteAssetUrl(entry.iconUrl, baseUrl) ??
|
||||
resolveRemoteAssetUrl(entry.icon_url, baseUrl) ??
|
||||
resolveRemoteAssetUrl(entry.logo, baseUrl) ??
|
||||
resolveRemoteAssetUrl(entry.logoUrl, baseUrl) ??
|
||||
resolveRemoteAssetUrl(entry.logo_url, baseUrl) ??
|
||||
resolveRemoteAssetUrl(entry.thumbnail, baseUrl) ??
|
||||
resolveRemoteAssetUrl(entry.thumb, baseUrl) ??
|
||||
resolveRemoteAssetUrl(entry.avatar, baseUrl) ??
|
||||
resolveRemoteAssetUrl(iconBlock?.en_us, baseUrl) ??
|
||||
resolveRemoteAssetUrl(logoBlock?.en_us, baseUrl)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeEntry(
|
||||
entry: Record<string, unknown>,
|
||||
index: number,
|
||||
baseUrl?: string,
|
||||
): AppTemplate | null {
|
||||
const name =
|
||||
asString(entry.name) ??
|
||||
asString(entry.title) ??
|
||||
asString(entry.app_name) ??
|
||||
"";
|
||||
const image =
|
||||
asString(entry.image) ??
|
||||
asString(entry.docker_image) ??
|
||||
asString(entry.container_image) ??
|
||||
asString(entry.repo) ??
|
||||
buildImageWithVersion(asString(entry.image), asString(entry.version));
|
||||
|
||||
if (!name || !image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id =
|
||||
(asString(entry.id) ??
|
||||
asString(entry.slug) ??
|
||||
asString(entry.appid) ??
|
||||
slugify(name)) ||
|
||||
`remote-${index}`;
|
||||
|
||||
const presets = normalizePresetList(entry);
|
||||
const presentation = normalizePresentation(entry, baseUrl);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
desc:
|
||||
asString(entry.desc) ??
|
||||
asString(entry.description) ??
|
||||
asString(entry.short_desc) ??
|
||||
asString(entry.subtitle) ??
|
||||
"Remote app catalog entry",
|
||||
category:
|
||||
asString(entry.category) ??
|
||||
asString(entry.type) ??
|
||||
asString(entry.section) ??
|
||||
"Remote",
|
||||
icon: normalizeIcon(entry, baseUrl),
|
||||
image,
|
||||
defaultPort: normalizePorts(
|
||||
entry.defaultPort ??
|
||||
entry.ports ??
|
||||
entry.port_map ??
|
||||
entry.portMap ??
|
||||
entry.port,
|
||||
),
|
||||
defaultEnv: normalizeEnv(
|
||||
entry.defaultEnv ?? entry.env ?? entry.environment ?? entry.envs,
|
||||
),
|
||||
defaultVolumes:
|
||||
normalizeVolumes(
|
||||
entry.defaultVolumes ?? entry.volumes ?? entry.storage ?? entry.mounts,
|
||||
) || undefined,
|
||||
defaultCommand: asString(entry.defaultCommand) ?? asString(entry.command),
|
||||
defaultNetwork: asString(entry.defaultNetwork) ?? asString(entry.network),
|
||||
restartPolicy:
|
||||
asString(entry.restartPolicy) ??
|
||||
asString(entry.restart) ??
|
||||
asString(entry.restart_policy) ??
|
||||
"unless-stopped",
|
||||
popular: Boolean(entry.popular ?? entry.featured ?? false),
|
||||
tags: normalizeTags(entry),
|
||||
presentation,
|
||||
presets: presets.length > 0 ? presets : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractCandidateEntries(
|
||||
payload: unknown,
|
||||
): Record<string, unknown>[] {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
.map((item) => asRecord(item))
|
||||
.filter((item): item is Record<string, unknown> => Boolean(item));
|
||||
}
|
||||
|
||||
const record = asRecord(payload);
|
||||
if (!record) return [];
|
||||
|
||||
for (const key of [
|
||||
"apps",
|
||||
"templates",
|
||||
"data",
|
||||
"items",
|
||||
"catalog",
|
||||
"entries",
|
||||
]) {
|
||||
const value = record[key];
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => asRecord(item))
|
||||
.filter((item): item is Record<string, unknown> => Boolean(item));
|
||||
}
|
||||
}
|
||||
|
||||
return [record];
|
||||
}
|
||||
|
||||
export function dedupeTemplates(templates: AppTemplate[]): AppTemplate[] {
|
||||
const seen = new Set<string>();
|
||||
return templates.filter((template) => {
|
||||
if (seen.has(template.id)) return false;
|
||||
seen.add(template.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { CatalogSourceMode } from "./types";
|
||||
import { slugify } from "./utils";
|
||||
|
||||
export function normalizeGithubManifestUrl(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
|
||||
if (parsed.hostname === "github.com" && parsed.pathname.includes("/blob/")) {
|
||||
const parts = parsed.pathname.split("/").filter(Boolean);
|
||||
const owner = parts[0];
|
||||
const repo = parts[1];
|
||||
const branch = parts[3];
|
||||
const filePath = parts.slice(4).join("/");
|
||||
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${filePath}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export function normalizeGithubArchiveUrl(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
|
||||
if (parsed.hostname === "github.com") {
|
||||
const match = parsed.pathname.match(
|
||||
/^\/([^/]+)\/([^/]+)\/archive\/refs\/(heads|tags)\/([^/]+)\.zip$/,
|
||||
);
|
||||
|
||||
if (match) {
|
||||
const [, owner, repo, refType, refName] = match;
|
||||
return `https://codeload.github.com/${owner}/${repo}/zip/refs/${refType}/${refName}`;
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export function getFallbackIdFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
const lastSegment = segments.at(-1) ?? "remote-manifest";
|
||||
const normalizedLast = lastSegment.toLowerCase();
|
||||
|
||||
if (
|
||||
/^(config\.json|docker-compose\.ya?ml|compose\.ya?ml|umbrel-app\.ya?ml|app\.json|manifest\.json|manifest\.ya?ml)$/.test(
|
||||
normalizedLast,
|
||||
)
|
||||
) {
|
||||
return slugify(segments.at(-2) ?? lastSegment);
|
||||
}
|
||||
|
||||
return slugify(lastSegment.replace(/\.(json|ya?ml)$/i, ""));
|
||||
} catch {
|
||||
return slugify(url.split("/").pop() ?? "remote-manifest");
|
||||
}
|
||||
}
|
||||
|
||||
function isZipUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return /\.zip$/i.test(parsed.pathname);
|
||||
} catch {
|
||||
return /\.zip($|\?)/i.test(url);
|
||||
}
|
||||
}
|
||||
|
||||
function isZipContentType(contentType?: string | null): boolean {
|
||||
if (!contentType) return false;
|
||||
|
||||
const normalized = contentType.toLowerCase();
|
||||
return (
|
||||
normalized.includes("zip") ||
|
||||
normalized.includes("compressed") ||
|
||||
normalized.includes("x-zip")
|
||||
);
|
||||
}
|
||||
|
||||
function getContentDispositionFileName(header?: string | null): string {
|
||||
if (!header) return "";
|
||||
|
||||
const utf8Match = header.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (utf8Match?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(utf8Match[1]);
|
||||
} catch {
|
||||
return utf8Match[1];
|
||||
}
|
||||
}
|
||||
|
||||
const plainMatch = header.match(/filename="?([^";]+)"?/i);
|
||||
return plainMatch?.[1] ?? "";
|
||||
}
|
||||
|
||||
export function isZipResponse(response: Response, url: string): boolean {
|
||||
const contentType = response.headers.get("content-type");
|
||||
const fileName = getContentDispositionFileName(
|
||||
response.headers.get("content-disposition"),
|
||||
);
|
||||
|
||||
return (
|
||||
isZipUrl(url) || isZipContentType(contentType) || /\.zip$/i.test(fileName)
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchRemoteResponse(url: string): Promise<Response> {
|
||||
const parsedUrl = new URL(url);
|
||||
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
||||
throw new Error("Remote catalog URL must use HTTP or HTTPS");
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept:
|
||||
"application/json, application/yaml, text/yaml, text/x-yaml, text/plain, application/octet-stream, */*",
|
||||
"User-Agent": "vps-panel-app-catalog/1.0",
|
||||
},
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Remote request failed with status ${response.status} for ${response.url}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export function normalizeRemoteUrl(
|
||||
source: CatalogSourceMode,
|
||||
url: string,
|
||||
): string {
|
||||
const manifestNormalized = normalizeGithubManifestUrl(url);
|
||||
return source === "github-archive"
|
||||
? normalizeGithubArchiveUrl(url)
|
||||
: normalizeGithubArchiveUrl(manifestNormalized);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { APP_TEMPLATES } from "../../config/app-templates";
|
||||
import {
|
||||
getCachedCatalog,
|
||||
getCacheKey,
|
||||
makeCachedResult,
|
||||
setCachedCatalog,
|
||||
} from "./cache";
|
||||
import { CatalogSyncResult, CatalogSourceMode } from "./types";
|
||||
import { parseArchiveResponse } from "./archive";
|
||||
import { dedupeTemplates } from "./normalize";
|
||||
import { normalizeSingleManifest } from "./manifest-parsers";
|
||||
import {
|
||||
fetchRemoteResponse,
|
||||
getFallbackIdFromUrl,
|
||||
isZipResponse,
|
||||
normalizeRemoteUrl,
|
||||
} from "./remote";
|
||||
import {
|
||||
CatalogSourceValidationError,
|
||||
isLikelyHtmlDocument,
|
||||
parseStructuredText,
|
||||
} from "./utils";
|
||||
|
||||
function detectTextFormat(text: string): string {
|
||||
return text.trim().startsWith("{") || text.trim().startsWith("[")
|
||||
? "json"
|
||||
: "yaml";
|
||||
}
|
||||
|
||||
async function loadRemoteTextCatalog(
|
||||
source: Extract<CatalogSourceMode, "auto-detect" | "manifest-url">,
|
||||
url: string,
|
||||
label: string,
|
||||
): Promise<CatalogSyncResult> {
|
||||
const normalizedUrl = normalizeRemoteUrl(source, url);
|
||||
const response = await fetchRemoteResponse(normalizedUrl);
|
||||
|
||||
if (isZipResponse(response, normalizedUrl)) {
|
||||
return parseArchiveResponse(response, source, label, normalizedUrl);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (isLikelyHtmlDocument(text, response.headers.get("content-type"))) {
|
||||
throw new CatalogSourceValidationError();
|
||||
}
|
||||
|
||||
const payload = parseStructuredText(text);
|
||||
const fallbackId = getFallbackIdFromUrl(normalizedUrl);
|
||||
|
||||
return {
|
||||
templates: dedupeTemplates(
|
||||
normalizeSingleManifest(payload, fallbackId, normalizedUrl),
|
||||
),
|
||||
meta: {
|
||||
source,
|
||||
label,
|
||||
url: normalizedUrl,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
isRemote: true,
|
||||
format: detectTextFormat(text),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadGithubArchiveCatalog(
|
||||
url: string,
|
||||
): Promise<CatalogSyncResult> {
|
||||
const normalizedUrl = normalizeRemoteUrl("github-archive", url);
|
||||
const response = await fetchRemoteResponse(normalizedUrl);
|
||||
return parseArchiveResponse(
|
||||
response,
|
||||
"github-archive",
|
||||
"GitHub Archive ZIP",
|
||||
normalizedUrl,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCatalogTemplates(
|
||||
source: CatalogSourceMode,
|
||||
url?: string,
|
||||
): Promise<CatalogSyncResult> {
|
||||
const fetchedAt = new Date().toISOString();
|
||||
|
||||
if (source === "local") {
|
||||
return {
|
||||
templates: APP_TEMPLATES,
|
||||
meta: {
|
||||
source,
|
||||
label: "Built-in Catalog",
|
||||
fetchedAt,
|
||||
isRemote: false,
|
||||
cached: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!url?.trim()) {
|
||||
throw new Error("Remote catalog URL is required");
|
||||
}
|
||||
|
||||
const trimmedUrl = url.trim();
|
||||
const cacheKey = getCacheKey(source, trimmedUrl);
|
||||
const cachedEntry = getCachedCatalog(cacheKey);
|
||||
if (cachedEntry) {
|
||||
return makeCachedResult(cachedEntry);
|
||||
}
|
||||
|
||||
const result =
|
||||
source === "auto-detect"
|
||||
? await loadRemoteTextCatalog(source, trimmedUrl, "Custom Source")
|
||||
: source === "manifest-url"
|
||||
? await loadRemoteTextCatalog(source, trimmedUrl, "Remote Manifest URL")
|
||||
: await loadGithubArchiveCatalog(trimmedUrl);
|
||||
|
||||
if (result.templates.length === 0) {
|
||||
throw new Error(
|
||||
"No app templates could be extracted from the remote source",
|
||||
);
|
||||
}
|
||||
|
||||
return setCachedCatalog(cacheKey, result.templates, result.meta);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { AppTemplate } from "../../config/app-templates";
|
||||
|
||||
export type CatalogSourceMode =
|
||||
| "local"
|
||||
| "auto-detect"
|
||||
| "manifest-url"
|
||||
| "github-archive";
|
||||
|
||||
export interface CatalogMeta {
|
||||
source: CatalogSourceMode;
|
||||
label: string;
|
||||
url?: string;
|
||||
fetchedAt: string;
|
||||
isRemote: boolean;
|
||||
cached?: boolean;
|
||||
expiresAt?: string;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
export interface CatalogSyncResult {
|
||||
templates: AppTemplate[];
|
||||
meta: CatalogMeta;
|
||||
}
|
||||
|
||||
export interface CatalogSourceOption {
|
||||
id: CatalogSourceMode;
|
||||
label: string;
|
||||
description: string;
|
||||
requiresUrl: boolean;
|
||||
placeholder?: string;
|
||||
exampleUrl?: string;
|
||||
}
|
||||
|
||||
export interface CacheEntry {
|
||||
templates: AppTemplate[];
|
||||
meta: CatalogMeta;
|
||||
expiresAt: number;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import yaml from "js-yaml";
|
||||
|
||||
export const REMOTE_CATALOG_SOURCE_HELP =
|
||||
"Custom source must point to a raw JSON/YAML app manifest or a direct ZIP archive URL, not a browser HTML page. Use a raw file URL or a repository archive URL.";
|
||||
|
||||
export class CatalogSourceValidationError extends Error {
|
||||
constructor(message = REMOTE_CATALOG_SOURCE_HELP) {
|
||||
super(message);
|
||||
this.name = "CatalogSourceValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
export function asNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function toStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildImageWithVersion(
|
||||
image?: string,
|
||||
version?: string,
|
||||
): string {
|
||||
const imageValue = image?.trim() ?? "";
|
||||
const versionValue = version?.trim() ?? "";
|
||||
|
||||
if (!imageValue) return "";
|
||||
if (!versionValue) return imageValue;
|
||||
|
||||
const lastColon = imageValue.lastIndexOf(":");
|
||||
const lastSlash = imageValue.lastIndexOf("/");
|
||||
if (lastColon > lastSlash) {
|
||||
return imageValue;
|
||||
}
|
||||
|
||||
return `${imageValue}:${versionValue}`;
|
||||
}
|
||||
|
||||
export function isLikelyHtmlDocument(
|
||||
text: string,
|
||||
contentType?: string | null,
|
||||
): boolean {
|
||||
if (contentType?.toLowerCase().includes("text/html")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const preview = text.trimStart().slice(0, 4096).toLowerCase();
|
||||
if (!preview) return false;
|
||||
|
||||
return (
|
||||
preview.startsWith("<!doctype html") ||
|
||||
preview.startsWith("<html") ||
|
||||
preview.includes("<head") ||
|
||||
preview.includes("<body") ||
|
||||
preview.includes("<style") ||
|
||||
preview.includes("<script")
|
||||
);
|
||||
}
|
||||
|
||||
export function parseStructuredText(text: string): unknown {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return {};
|
||||
|
||||
if (isLikelyHtmlDocument(trimmed)) {
|
||||
throw new CatalogSourceValidationError();
|
||||
}
|
||||
|
||||
try {
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
return JSON.parse(trimmed);
|
||||
}
|
||||
|
||||
return yaml.load(trimmed);
|
||||
} catch (error) {
|
||||
if (error instanceof CatalogSourceValidationError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new CatalogSourceValidationError(
|
||||
"Custom source could not be parsed as JSON or YAML. Check that the URL returns a raw app manifest or use a direct ZIP archive URL.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import type { Server } from "@prisma/client";
|
||||
import prisma from "../lib/prisma";
|
||||
import { auditLog } from "./audit.service";
|
||||
import { dispatchRuntimeNotification } from "./notification.service";
|
||||
import * as ssh from "./ssh.service";
|
||||
import { APP_TEMPLATES } from "../config/app-templates";
|
||||
|
||||
type DockerInspectRuntime = {
|
||||
Config?: {
|
||||
Image?: string;
|
||||
Env?: string[];
|
||||
Cmd?: string[] | null;
|
||||
};
|
||||
HostConfig?: {
|
||||
RestartPolicy?: { Name?: string | null };
|
||||
PortBindings?: Record<
|
||||
string,
|
||||
Array<{ HostIp?: string; HostPort?: string }> | null
|
||||
>;
|
||||
NetworkMode?: string | null;
|
||||
};
|
||||
Mounts?: Array<{
|
||||
Source?: string;
|
||||
Destination?: string;
|
||||
RW?: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
type DockerPortBindings = Record<
|
||||
string,
|
||||
Array<{ HostIp?: string; HostPort?: string }> | null
|
||||
>;
|
||||
|
||||
function formatEnvLines(env?: string[] | null): string {
|
||||
return (env ?? []).filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function formatMountBindings(mounts?: DockerInspectRuntime["Mounts"]): string {
|
||||
return (mounts ?? [])
|
||||
.map((mount) => {
|
||||
if (!mount.Source || !mount.Destination) return "";
|
||||
return `${mount.Source}:${mount.Destination}${mount.RW === false ? ":ro" : ""}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function formatPortBindings(bindings?: DockerPortBindings): string {
|
||||
if (!bindings) return "";
|
||||
|
||||
return Object.entries(bindings)
|
||||
.flatMap(([containerPortSpec, hostBindings]) => {
|
||||
if (!hostBindings || hostBindings.length === 0) return [];
|
||||
const [containerPort, protocol = "tcp"] = containerPortSpec.split("/");
|
||||
|
||||
return hostBindings
|
||||
.map((binding) => {
|
||||
const hostPort = binding.HostPort?.trim();
|
||||
if (!hostPort) return "";
|
||||
return `${hostPort}:${containerPort}${protocol !== "tcp" ? `/${protocol}` : ""}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
})
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function formatCommandParts(cmd?: string[] | null): string {
|
||||
return (cmd ?? []).filter(Boolean).join(" ").trim();
|
||||
}
|
||||
|
||||
function firstDefined(...values: Array<string | undefined>): string {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
const TRUSTED_APP_SENSITIVE_PATHS: Record<string, string[]> = {
|
||||
docker: ["/var/run/docker.sock"],
|
||||
portainer: ["/var/run/docker.sock"],
|
||||
traefik: ["/var/run/docker.sock"],
|
||||
};
|
||||
|
||||
function resolveAppMountValidation(appId: string) {
|
||||
const allowSensitivePaths = TRUSTED_APP_SENSITIVE_PATHS[appId];
|
||||
return allowSensitivePaths ? { allowSensitivePaths } : undefined;
|
||||
}
|
||||
|
||||
type RebuildRuntimeConfig = {
|
||||
image: string;
|
||||
ports: string;
|
||||
env: string;
|
||||
volumes: string;
|
||||
network: string;
|
||||
restartPolicy: string;
|
||||
command: string;
|
||||
};
|
||||
|
||||
async function resolveRuntimeConfig(install: {
|
||||
appId: string;
|
||||
port: string | null;
|
||||
containerName: string;
|
||||
server: Server;
|
||||
}): Promise<RebuildRuntimeConfig> {
|
||||
try {
|
||||
const inspect = (await ssh.dockerInspect(
|
||||
install.server,
|
||||
install.containerName!,
|
||||
)) as DockerInspectRuntime;
|
||||
|
||||
return {
|
||||
image: inspect.Config?.Image?.trim() || "",
|
||||
ports: formatPortBindings(inspect.HostConfig?.PortBindings),
|
||||
env: formatEnvLines(inspect.Config?.Env),
|
||||
volumes: formatMountBindings(inspect.Mounts),
|
||||
network: inspect.HostConfig?.NetworkMode?.trim() || "bridge",
|
||||
restartPolicy:
|
||||
inspect.HostConfig?.RestartPolicy?.Name?.trim() || "unless-stopped",
|
||||
command: formatCommandParts(inspect.Config?.Cmd),
|
||||
};
|
||||
} catch {
|
||||
const template = APP_TEMPLATES.find((item) => item.id === install.appId);
|
||||
if (!template) {
|
||||
throw new Error(
|
||||
"Current runtime configuration is unavailable, so this app cannot be rebuilt automatically",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
image: template.image,
|
||||
ports: firstDefined(install.port || undefined, template.defaultPort),
|
||||
env: firstDefined(template.defaultEnv),
|
||||
volumes: firstDefined(template.defaultVolumes),
|
||||
network: firstDefined(template.defaultNetwork, "bridge"),
|
||||
restartPolicy: firstDefined(template.restartPolicy, "unless-stopped"),
|
||||
command: firstDefined(template.defaultCommand),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function rebuildAppInstall(options: {
|
||||
installId: string;
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
}) {
|
||||
const install = await prisma.appInstall.findUnique({
|
||||
where: { id: options.installId },
|
||||
include: { server: true },
|
||||
});
|
||||
|
||||
if (!install) {
|
||||
throw new Error("Install not found");
|
||||
}
|
||||
|
||||
if (!install.containerName) {
|
||||
throw new Error("Install has no container name to rebuild");
|
||||
}
|
||||
|
||||
const dockerStatus = await ssh.getDockerRuntimeStatus(install.server);
|
||||
if (!dockerStatus.available) {
|
||||
throw new Error(
|
||||
dockerStatus.reason || "Docker is not available on the selected server",
|
||||
);
|
||||
}
|
||||
|
||||
const runtimeConfig = await resolveRuntimeConfig({
|
||||
appId: install.appId,
|
||||
port: install.port,
|
||||
containerName: install.containerName,
|
||||
server: install.server,
|
||||
});
|
||||
if (!runtimeConfig.image) {
|
||||
throw new Error("Unable to determine which image should be rebuilt");
|
||||
}
|
||||
|
||||
await prisma.appInstall.update({
|
||||
where: { id: install.id },
|
||||
data: { status: "INSTALLING", error: null },
|
||||
});
|
||||
|
||||
let pulledLatestImage = false;
|
||||
let usedImageCache = false;
|
||||
|
||||
try {
|
||||
try {
|
||||
await ssh.dockerPullImage(install.server, runtimeConfig.image);
|
||||
pulledLatestImage = true;
|
||||
} catch {
|
||||
usedImageCache = true;
|
||||
}
|
||||
|
||||
try {
|
||||
await ssh.dockerAction(install.server, install.containerName, "stop");
|
||||
} catch {
|
||||
// Best effort only.
|
||||
}
|
||||
|
||||
try {
|
||||
await ssh.dockerAction(install.server, install.containerName, "rm");
|
||||
} catch {
|
||||
// Best effort only.
|
||||
}
|
||||
|
||||
const dockerId = await ssh.runContainer(install.server, {
|
||||
name: install.containerName,
|
||||
image: runtimeConfig.image,
|
||||
ports: runtimeConfig.ports,
|
||||
env: runtimeConfig.env,
|
||||
volumes: runtimeConfig.volumes,
|
||||
network: runtimeConfig.network,
|
||||
restartPolicy: runtimeConfig.restartPolicy,
|
||||
command: runtimeConfig.command,
|
||||
mountValidation: resolveAppMountValidation(install.appId),
|
||||
});
|
||||
|
||||
const normalizedDockerId = dockerId.trim().slice(0, 12);
|
||||
|
||||
await prisma.container.updateMany({
|
||||
where: {
|
||||
serverId: install.serverId,
|
||||
name: { equals: install.containerName, mode: "insensitive" },
|
||||
},
|
||||
data: {
|
||||
image: runtimeConfig.image,
|
||||
status: "RUNNING",
|
||||
dockerId: normalizedDockerId || null,
|
||||
},
|
||||
});
|
||||
|
||||
const updated = await prisma.appInstall.update({
|
||||
where: { id: install.id },
|
||||
data: { status: "RUNNING", error: null },
|
||||
include: {
|
||||
server: { select: { name: true, ip: true, organizationId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: options.userId,
|
||||
serverId: install.serverId,
|
||||
action: "APP_REBUILD",
|
||||
category: "SYSTEM",
|
||||
level: "SUCCESS",
|
||||
message: `App "${install.appName}" rebuilt on "${install.server.name}"`,
|
||||
meta: {
|
||||
image: runtimeConfig.image,
|
||||
ports: runtimeConfig.ports,
|
||||
network: runtimeConfig.network,
|
||||
pulledLatestImage,
|
||||
usedImageCache,
|
||||
},
|
||||
});
|
||||
|
||||
if (options.organizationId) {
|
||||
await dispatchRuntimeNotification({
|
||||
organizationId: options.organizationId,
|
||||
action: "app_deploy",
|
||||
title: `App rebuilt: ${install.appName}`,
|
||||
message: `App ${install.appName} was rebuilt successfully on ${install.server.name} using image ${runtimeConfig.image}.`,
|
||||
serverId: install.serverId,
|
||||
resourceType: "app_install",
|
||||
resourceId: install.id,
|
||||
metadata: {
|
||||
installId: install.id,
|
||||
appId: install.appId,
|
||||
appName: install.appName,
|
||||
image: runtimeConfig.image,
|
||||
serverId: install.serverId,
|
||||
serverName: install.server.name,
|
||||
pulledLatestImage,
|
||||
usedImageCache,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
updated,
|
||||
image: runtimeConfig.image,
|
||||
pulledLatestImage,
|
||||
usedImageCache,
|
||||
};
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to rebuild app";
|
||||
|
||||
await prisma.appInstall.update({
|
||||
where: { id: install.id },
|
||||
data: { status: "FAILED", error: message },
|
||||
});
|
||||
|
||||
await prisma.container.updateMany({
|
||||
where: {
|
||||
serverId: install.serverId,
|
||||
name: { equals: install.containerName, mode: "insensitive" },
|
||||
},
|
||||
data: { status: "ERROR" },
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
userId: options.userId,
|
||||
serverId: install.serverId,
|
||||
action: "APP_REBUILD_FAILED",
|
||||
category: "SYSTEM",
|
||||
level: "ERROR",
|
||||
message: `App "${install.appName}" rebuild failed on "${install.server.name}": ${message}`,
|
||||
meta: {
|
||||
image: runtimeConfig.image,
|
||||
ports: runtimeConfig.ports,
|
||||
network: runtimeConfig.network,
|
||||
},
|
||||
});
|
||||
|
||||
if (options.organizationId) {
|
||||
await dispatchRuntimeNotification({
|
||||
organizationId: options.organizationId,
|
||||
action: "app_build_error",
|
||||
title: `App rebuild failed: ${install.appName}`,
|
||||
message: `App ${install.appName} failed to rebuild on ${install.server.name}. ${message}`,
|
||||
serverId: install.serverId,
|
||||
resourceType: "app_install",
|
||||
resourceId: install.id,
|
||||
metadata: {
|
||||
installId: install.id,
|
||||
appId: install.appId,
|
||||
appName: install.appName,
|
||||
image: runtimeConfig.image,
|
||||
serverId: install.serverId,
|
||||
serverName: install.server.name,
|
||||
error: message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw error instanceof Error ? error : new Error(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import prisma from "../lib/prisma";
|
||||
import { LogCategory, LogLevel } from "@prisma/client";
|
||||
|
||||
interface LogEntry {
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
serverId?: string;
|
||||
action: string;
|
||||
category: LogCategory;
|
||||
level?: LogLevel;
|
||||
message: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function resolveOrganizationId(entry: LogEntry) {
|
||||
if (entry.organizationId) return entry.organizationId;
|
||||
|
||||
if (entry.serverId) {
|
||||
const server = await prisma.server.findUnique({
|
||||
where: { id: entry.serverId },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
|
||||
if (server) return server.organizationId;
|
||||
}
|
||||
|
||||
if (entry.userId) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: entry.userId },
|
||||
select: { activeOrganizationId: true },
|
||||
});
|
||||
|
||||
if (user?.activeOrganizationId) return user.activeOrganizationId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function auditLog(entry: LogEntry): Promise<void> {
|
||||
try {
|
||||
const organizationId = await resolveOrganizationId(entry);
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: entry.userId,
|
||||
organizationId,
|
||||
serverId: entry.serverId,
|
||||
action: entry.action,
|
||||
category: entry.category,
|
||||
level: entry.level ?? LogLevel.INFO,
|
||||
message: entry.message,
|
||||
meta: entry.meta as object | undefined,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Never let logging crash the main flow
|
||||
console.error("[AuditLog] Failed to write log:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLogs(opts: {
|
||||
organizationId?: string;
|
||||
serverId?: string;
|
||||
category?: LogCategory;
|
||||
level?: LogLevel;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const {
|
||||
organizationId,
|
||||
serverId,
|
||||
category,
|
||||
level,
|
||||
search,
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
} = opts;
|
||||
|
||||
const [logs, total] = await prisma.$transaction([
|
||||
prisma.auditLog.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
serverId,
|
||||
category,
|
||||
level,
|
||||
...(search
|
||||
? { message: { contains: search, mode: "insensitive" } }
|
||||
: {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
server: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.auditLog.count({
|
||||
where: {
|
||||
organizationId,
|
||||
serverId,
|
||||
category,
|
||||
level,
|
||||
...(search
|
||||
? { message: { contains: search, mode: "insensitive" } }
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { logs, total };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,864 @@
|
||||
import { Container, ProxyType, Server } from "@prisma/client";
|
||||
import yaml from "js-yaml";
|
||||
import {
|
||||
DockerContainerInspect,
|
||||
dockerInspect,
|
||||
exec,
|
||||
resolveSslCertificate,
|
||||
restartManagedService,
|
||||
} from "./ssh.service";
|
||||
|
||||
export interface DomainProvisioningResult {
|
||||
upstream: string;
|
||||
configPath: string;
|
||||
reloadTarget: string;
|
||||
}
|
||||
|
||||
export class DomainProvisioningError extends Error {}
|
||||
|
||||
interface ContainerPortBinding {
|
||||
containerPort: number;
|
||||
protocol: string;
|
||||
hostPort: number | null;
|
||||
}
|
||||
|
||||
interface ContainerUpstreamTarget {
|
||||
upstream: string;
|
||||
selectedPort: number;
|
||||
}
|
||||
|
||||
function escapeShellArg(value: string): string {
|
||||
return `'${value.replace(/'/g, `"'"'`)}'`;
|
||||
}
|
||||
|
||||
function escapeDoubleQuotedShellArg(value: string): string {
|
||||
return `"${value.replace(/[\\"$`]/g, "\\$&")}"`;
|
||||
}
|
||||
|
||||
function shellCommand(command: string): string {
|
||||
return `sh -c ${escapeShellArg(command)}`;
|
||||
}
|
||||
|
||||
function privilegedShellCommand(server: Server, command: string): string {
|
||||
return server.username === "root"
|
||||
? shellCommand(command)
|
||||
: `sudo ${shellCommand(command)}`;
|
||||
}
|
||||
|
||||
async function execPrivileged(server: Server, script: string): Promise<string> {
|
||||
const result = await exec(server, privilegedShellCommand(server, script));
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || result.stdout || "Remote command failed");
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async function execPrivilegedLoose(
|
||||
server: Server,
|
||||
script: string,
|
||||
): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
||||
const result = await exec(server, privilegedShellCommand(server, script));
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
code: result.code ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function pathExists(server: Server, filePath: string): Promise<boolean> {
|
||||
const result = await exec(
|
||||
server,
|
||||
shellCommand(
|
||||
`[ -e ${escapeDoubleQuotedShellArg(filePath)} ] && printf yes || printf no`,
|
||||
),
|
||||
);
|
||||
|
||||
return (result.stdout || "").trim() === "yes";
|
||||
}
|
||||
|
||||
async function pathExistsPrivileged(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
): Promise<boolean> {
|
||||
const result = await execPrivilegedLoose(
|
||||
server,
|
||||
`[ -e ${escapeDoubleQuotedShellArg(filePath)} ] && printf yes || printf no`,
|
||||
);
|
||||
|
||||
return (result.stdout || "").trim() === "yes";
|
||||
}
|
||||
|
||||
async function readPrivilegedFile(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
): Promise<string | null> {
|
||||
if (!(await pathExistsPrivileged(server, filePath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return execPrivileged(server, `cat ${escapeDoubleQuotedShellArg(filePath)}`);
|
||||
}
|
||||
|
||||
async function writePrivilegedFile(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const encoded = Buffer.from(content, "utf8").toString("base64");
|
||||
const parentDir = filePath.replace(/\/[^/]+$/, "") || "/";
|
||||
|
||||
await execPrivileged(
|
||||
server,
|
||||
[
|
||||
`mkdir -p ${escapeDoubleQuotedShellArg(parentDir)}`,
|
||||
`printf %s ${escapeDoubleQuotedShellArg(encoded)} | base64 -d > ${escapeDoubleQuotedShellArg(filePath)}`,
|
||||
].join(" && "),
|
||||
);
|
||||
}
|
||||
|
||||
async function removePrivilegedFile(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
): Promise<void> {
|
||||
await execPrivileged(server, `rm -f ${escapeDoubleQuotedShellArg(filePath)}`);
|
||||
}
|
||||
|
||||
function sanitizeDomainFileName(domainName: string): string {
|
||||
return domainName.toLowerCase().replace(/[^a-z0-9.-]/g, "-");
|
||||
}
|
||||
|
||||
function sanitizeContainerFileName(containerName: string): string {
|
||||
return containerName.toLowerCase().replace(/[^a-z0-9.-]/g, "-");
|
||||
}
|
||||
|
||||
function buildManagedNginxFileBase(
|
||||
containerName: string,
|
||||
domainName: string,
|
||||
): string {
|
||||
return `${sanitizeContainerFileName(containerName)}--${sanitizeDomainFileName(domainName)}`;
|
||||
}
|
||||
|
||||
function findHttpBlockEnd(configContent: string): number | null {
|
||||
const httpMatch = /\bhttp\s*\{/.exec(configContent);
|
||||
if (!httpMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const braceStart = configContent.indexOf("{", httpMatch.index);
|
||||
if (braceStart < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
for (let index = braceStart; index < configContent.length; index += 1) {
|
||||
const char = configContent[index];
|
||||
if (char === "{") {
|
||||
depth += 1;
|
||||
} else if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureNginxSitesEnabledInclude(configContent: string): string {
|
||||
if (
|
||||
/include\s+\/etc\/nginx\/sites-enabled\/\*\.conf\s*;/.test(configContent)
|
||||
) {
|
||||
return configContent;
|
||||
}
|
||||
|
||||
const httpBlockEnd = findHttpBlockEnd(configContent);
|
||||
if (httpBlockEnd === null) {
|
||||
throw new DomainProvisioningError(
|
||||
"Could not find the http block in nginx.conf to register /etc/nginx/sites-enabled/*.conf.",
|
||||
);
|
||||
}
|
||||
|
||||
const includeLine = "\n include /etc/nginx/sites-enabled/*.conf;\n";
|
||||
return `${configContent.slice(0, httpBlockEnd)}${includeLine}${configContent.slice(httpBlockEnd)}`;
|
||||
}
|
||||
|
||||
function normalizeNginxFailureMessage(message: string): string {
|
||||
if (
|
||||
/socket\(\) \[::\]:80 failed \(97: Address family not supported by protocol\)/i.test(
|
||||
message,
|
||||
)
|
||||
) {
|
||||
return [
|
||||
"Nginx validation failed on the target server because an IPv6 listener like `listen [::]:80` is enabled, but the host does not support IPv6.",
|
||||
"Adjust the existing Nginx configuration on that server to remove or conditionalize the IPv6 listener, then retry Add Domain.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
async function detectNginxMainConfigPath(server: Server): Promise<string> {
|
||||
const candidates = new Set([
|
||||
"/etc/nginx/nginx.conf",
|
||||
"/etc/openresty/nginx.conf",
|
||||
"/usr/local/nginx/conf/nginx.conf",
|
||||
"/usr/local/openresty/nginx/conf/nginx.conf",
|
||||
"/usr/local/etc/nginx/nginx.conf",
|
||||
"/opt/nginx/conf/nginx.conf",
|
||||
"/www/server/nginx/conf/nginx.conf",
|
||||
]);
|
||||
|
||||
const versionOutput = await execPrivilegedLoose(
|
||||
server,
|
||||
"nginx -V 2>&1 || true",
|
||||
);
|
||||
const combinedVersionOutput = `${versionOutput.stdout}\n${versionOutput.stderr}`;
|
||||
const confPathMatch = combinedVersionOutput.match(/--conf-path=([^\s]+)/);
|
||||
if (confPathMatch?.[1]) {
|
||||
candidates.add(confPathMatch[1].trim());
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (await pathExistsPrivileged(server, candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const discoveryResult = await execPrivilegedLoose(
|
||||
server,
|
||||
[
|
||||
"for root in /etc /usr/local /opt /www /home /srv; do",
|
||||
' if [ -d "$root" ]; then',
|
||||
' found=$(find -L "$root" -maxdepth 6 -type f -name nginx.conf 2>/dev/null | head -n 1);',
|
||||
' if [ -n "$found" ]; then printf "%s\\n" "$found"; exit 0; fi',
|
||||
" fi",
|
||||
"done",
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
const discoveredPath = `${discoveryResult.stdout || ""}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
|
||||
if (discoveredPath && (await pathExistsPrivileged(server, discoveredPath))) {
|
||||
return discoveredPath;
|
||||
}
|
||||
|
||||
throw new DomainProvisioningError(
|
||||
`nginx.conf was not found on ${server.name}. Checked Nginx --conf-path and common locations: ${Array.from(candidates).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureNginxSitesEnabledRegistration(
|
||||
server: Server,
|
||||
): Promise<void> {
|
||||
const mainConfigPath = await detectNginxMainConfigPath(server);
|
||||
const currentContent = await readPrivilegedFile(server, mainConfigPath);
|
||||
|
||||
if (currentContent === null) {
|
||||
throw new DomainProvisioningError(
|
||||
`Unable to read Nginx main config at ${mainConfigPath}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextContent = ensureNginxSitesEnabledInclude(currentContent);
|
||||
if (nextContent !== currentContent) {
|
||||
await writePrivilegedFile(server, mainConfigPath, nextContent);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAndReloadNginx(
|
||||
server: Server,
|
||||
rollbackPaths: string[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
await execPrivileged(server, "nginx -t");
|
||||
await restartManagedService(server, "nginx");
|
||||
} catch (error) {
|
||||
for (const rollbackPath of rollbackPaths) {
|
||||
await removePrivilegedFile(server, rollbackPath).catch(() => undefined);
|
||||
}
|
||||
|
||||
throw new DomainProvisioningError(
|
||||
normalizeNginxFailureMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getContainerNetworkIp(inspect: DockerContainerInspect): string | null {
|
||||
const networkSettings = inspect.NetworkSettings as
|
||||
| {
|
||||
Networks?: Record<string, { IPAddress?: string }>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const networks = networkSettings?.Networks ?? {};
|
||||
for (const network of Object.values(networks)) {
|
||||
const ipAddress = `${network?.IPAddress ?? ""}`.trim();
|
||||
if (ipAddress) {
|
||||
return ipAddress;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getContainerPortBindings(
|
||||
inspect: DockerContainerInspect,
|
||||
): ContainerPortBinding[] {
|
||||
const networkSettings = inspect.NetworkSettings as
|
||||
| {
|
||||
Ports?: Record<string, Array<{ HostPort?: string }> | null>;
|
||||
}
|
||||
| undefined;
|
||||
const config = inspect.Config as
|
||||
| {
|
||||
ExposedPorts?: Record<string, Record<string, never>>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const bindings = new Map<string, ContainerPortBinding>();
|
||||
|
||||
for (const [rawPort, hosts] of Object.entries(networkSettings?.Ports ?? {})) {
|
||||
const [portRaw, protocolRaw] = rawPort.split("/");
|
||||
const containerPort = Number(portRaw);
|
||||
const protocol = protocolRaw || "tcp";
|
||||
if (!Number.isFinite(containerPort)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hosts || hosts.length === 0) {
|
||||
bindings.set(rawPort, {
|
||||
containerPort,
|
||||
protocol,
|
||||
hostPort: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const host of hosts) {
|
||||
const hostPort = Number(host?.HostPort ?? "");
|
||||
bindings.set(`${rawPort}:${host?.HostPort ?? "internal"}`, {
|
||||
containerPort,
|
||||
protocol,
|
||||
hostPort: Number.isFinite(hostPort) ? hostPort : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const rawPort of Object.keys(config?.ExposedPorts ?? {})) {
|
||||
const [portRaw, protocolRaw] = rawPort.split("/");
|
||||
const containerPort = Number(portRaw);
|
||||
const protocol = protocolRaw || "tcp";
|
||||
if (!Number.isFinite(containerPort)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = Array.from(bindings.values()).some(
|
||||
(binding) =>
|
||||
binding.containerPort === containerPort &&
|
||||
binding.protocol === protocol,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
bindings.set(rawPort, {
|
||||
containerPort,
|
||||
protocol,
|
||||
hostPort: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(bindings.values()).sort(
|
||||
(left, right) => left.containerPort - right.containerPort,
|
||||
);
|
||||
}
|
||||
|
||||
export function describeContainerTargetPorts(
|
||||
inspect: DockerContainerInspect,
|
||||
): Array<{
|
||||
containerPort: number;
|
||||
hostPort: number | null;
|
||||
protocol: string;
|
||||
label: string;
|
||||
}> {
|
||||
return getContainerPortBindings(inspect).map((binding) => ({
|
||||
...binding,
|
||||
label: binding.hostPort
|
||||
? `${binding.containerPort}/${binding.protocol} (published as ${binding.hostPort})`
|
||||
: `${binding.containerPort}/${binding.protocol} (container internal)`,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function resolveContainerUpstream(
|
||||
server: Server,
|
||||
container: Pick<Container, "id" | "name" | "dockerId" | "serverId">,
|
||||
targetPort: number,
|
||||
): Promise<ContainerUpstreamTarget> {
|
||||
const inspect = await dockerInspect(
|
||||
server,
|
||||
container.dockerId || container.name,
|
||||
);
|
||||
const bindings = getContainerPortBindings(inspect);
|
||||
const matching = bindings.find(
|
||||
(binding) => binding.containerPort === targetPort,
|
||||
);
|
||||
|
||||
if (!matching) {
|
||||
throw new Error(
|
||||
`Container ${container.name} does not expose port ${targetPort}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (matching.hostPort) {
|
||||
return {
|
||||
upstream: `http://127.0.0.1:${matching.hostPort}`,
|
||||
selectedPort: targetPort,
|
||||
};
|
||||
}
|
||||
|
||||
const containerIp = getContainerNetworkIp(inspect);
|
||||
if (!containerIp) {
|
||||
throw new Error(
|
||||
`Container ${container.name} has no reachable IP address for internal port ${targetPort}. Publish the port first or connect it to a reachable Docker network.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
upstream: `http://${containerIp}:${targetPort}`,
|
||||
selectedPort: targetPort,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNginxProxyLocation(upstream: string): string[] {
|
||||
return [
|
||||
" location / {",
|
||||
` proxy_pass ${upstream};`,
|
||||
" proxy_http_version 1.1;",
|
||||
" proxy_set_header Host $host;",
|
||||
" proxy_set_header X-Real-IP $remote_addr;",
|
||||
" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;",
|
||||
" proxy_set_header X-Forwarded-Proto $scheme;",
|
||||
" proxy_set_header Upgrade $http_upgrade;",
|
||||
' proxy_set_header Connection "upgrade";',
|
||||
" }",
|
||||
];
|
||||
}
|
||||
|
||||
function buildNginxConfig(
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
sslCertName?: string | null,
|
||||
): string {
|
||||
if (!sslCertName) {
|
||||
return [
|
||||
"server {",
|
||||
" listen 80;",
|
||||
` server_name ${domainName};`,
|
||||
"",
|
||||
...buildNginxProxyLocation(upstream),
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const certPath = `/etc/letsencrypt/live/${sslCertName}/fullchain.pem`;
|
||||
const keyPath = `/etc/letsencrypt/live/${sslCertName}/privkey.pem`;
|
||||
|
||||
return [
|
||||
"server {",
|
||||
" listen 80;",
|
||||
` server_name ${domainName};`,
|
||||
" return 301 https://$host$request_uri;",
|
||||
"}",
|
||||
"",
|
||||
"server {",
|
||||
" listen 443 ssl http2;",
|
||||
` server_name ${domainName};`,
|
||||
` ssl_certificate ${certPath};`,
|
||||
` ssl_certificate_key ${keyPath};`,
|
||||
" ssl_session_cache shared:SSL:10m;",
|
||||
" ssl_session_timeout 10m;",
|
||||
" ssl_protocols TLSv1.2 TLSv1.3;",
|
||||
" ssl_prefer_server_ciphers off;",
|
||||
"",
|
||||
...buildNginxProxyLocation(upstream),
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function provisionNginxConfig(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
containerName: string,
|
||||
sslEnabled: boolean,
|
||||
): Promise<DomainProvisioningResult> {
|
||||
const domainSafeName = sanitizeDomainFileName(domainName);
|
||||
const safeName = buildManagedNginxFileBase(containerName, domainName);
|
||||
const legacyConfigPath = `/etc/nginx/conf.d/portainer-domains/${domainSafeName}.conf`;
|
||||
const configPath = `/etc/nginx/sites-available/${safeName}.conf`;
|
||||
const enabledPath = `/etc/nginx/sites-enabled/${safeName}.conf`;
|
||||
const previousConfigPath = `/etc/nginx/sites-available/${domainSafeName}.conf`;
|
||||
const previousEnabledPath = `/etc/nginx/sites-enabled/${domainSafeName}.conf`;
|
||||
|
||||
await ensureNginxSitesEnabledRegistration(server);
|
||||
const sslCertificate = sslEnabled
|
||||
? await resolveSslCertificate(server, domainName).catch(() => null)
|
||||
: null;
|
||||
await writePrivilegedFile(
|
||||
server,
|
||||
configPath,
|
||||
buildNginxConfig(domainName, upstream, sslCertificate?.certName ?? null),
|
||||
);
|
||||
await execPrivileged(
|
||||
server,
|
||||
[
|
||||
"mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled",
|
||||
`ln -sfn ${escapeDoubleQuotedShellArg(configPath)} ${escapeDoubleQuotedShellArg(enabledPath)}`,
|
||||
`rm -f ${escapeDoubleQuotedShellArg(legacyConfigPath)}`,
|
||||
safeName === domainSafeName
|
||||
? "true"
|
||||
: `rm -f ${escapeDoubleQuotedShellArg(previousEnabledPath)} ${escapeDoubleQuotedShellArg(previousConfigPath)}`,
|
||||
].join(" && "),
|
||||
);
|
||||
await validateAndReloadNginx(server, [enabledPath, configPath]);
|
||||
|
||||
return {
|
||||
upstream,
|
||||
configPath,
|
||||
reloadTarget: "nginx",
|
||||
};
|
||||
}
|
||||
|
||||
function buildCaddyConfig(
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
sslEnabled: boolean,
|
||||
): string {
|
||||
const siteLabel = sslEnabled ? domainName : `http://${domainName}`;
|
||||
return [
|
||||
`${siteLabel} {`,
|
||||
` reverse_proxy ${upstream.replace(/^https?:\/\//, "")}`,
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function provisionCaddyConfig(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
sslEnabled: boolean,
|
||||
): Promise<DomainProvisioningResult> {
|
||||
const mainConfigCandidates = [
|
||||
"/etc/caddy/Caddyfile",
|
||||
"/usr/local/etc/caddy/Caddyfile",
|
||||
];
|
||||
|
||||
let mainConfigPath: string | null = null;
|
||||
let mainConfigContent: string | null = null;
|
||||
for (const candidate of mainConfigCandidates) {
|
||||
const content = await readPrivilegedFile(server, candidate);
|
||||
if (content !== null) {
|
||||
mainConfigPath = candidate;
|
||||
mainConfigContent = content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mainConfigPath || mainConfigContent === null) {
|
||||
throw new Error(
|
||||
`Caddyfile was not found on ${server.name}. Expected one of: ${mainConfigCandidates.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const importLine = "import /etc/caddy/portainer-domains/*.caddy";
|
||||
if (!mainConfigContent.includes(importLine)) {
|
||||
const nextContent = `${mainConfigContent.trimEnd()}\n\n${importLine}\n`;
|
||||
await writePrivilegedFile(server, mainConfigPath, nextContent);
|
||||
}
|
||||
|
||||
const configPath = `/etc/caddy/portainer-domains/${sanitizeDomainFileName(domainName)}.caddy`;
|
||||
await writePrivilegedFile(
|
||||
server,
|
||||
configPath,
|
||||
buildCaddyConfig(domainName, upstream, sslEnabled),
|
||||
);
|
||||
await execPrivileged(
|
||||
server,
|
||||
`caddy validate --config ${escapeDoubleQuotedShellArg(mainConfigPath)}`,
|
||||
);
|
||||
await execPrivileged(
|
||||
server,
|
||||
`caddy reload --config ${escapeDoubleQuotedShellArg(mainConfigPath)}`,
|
||||
);
|
||||
|
||||
return {
|
||||
upstream,
|
||||
configPath,
|
||||
reloadTarget: "caddy",
|
||||
};
|
||||
}
|
||||
|
||||
interface TraefikStaticConfigLocation {
|
||||
path: string;
|
||||
format: "yaml" | "toml";
|
||||
content: string;
|
||||
}
|
||||
|
||||
async function detectTraefikStaticConfig(
|
||||
server: Server,
|
||||
): Promise<TraefikStaticConfigLocation | null> {
|
||||
const candidates: Array<{ path: string; format: "yaml" | "toml" }> = [
|
||||
{ path: "/etc/traefik/traefik.yml", format: "yaml" },
|
||||
{ path: "/etc/traefik/traefik.yaml", format: "yaml" },
|
||||
{ path: "/etc/traefik/traefik.toml", format: "toml" },
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const content = await readPrivilegedFile(server, candidate.path);
|
||||
if (content !== null) {
|
||||
return { ...candidate, content };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureTraefikFileProvider(server: Server): Promise<string> {
|
||||
const dynamicDirectory = "/etc/traefik/dynamic/portainer";
|
||||
const staticConfig = await detectTraefikStaticConfig(server);
|
||||
if (!staticConfig) {
|
||||
throw new Error(
|
||||
"Traefik static config file was not found. Configure a file provider first so Portainer can write dynamic domain routes.",
|
||||
);
|
||||
}
|
||||
|
||||
if (staticConfig.format === "yaml") {
|
||||
const parsed =
|
||||
(yaml.load(staticConfig.content) as Record<string, unknown> | null) ?? {};
|
||||
const providers =
|
||||
typeof parsed.providers === "object" && parsed.providers !== null
|
||||
? (parsed.providers as Record<string, unknown>)
|
||||
: {};
|
||||
const fileProvider =
|
||||
typeof providers.file === "object" && providers.file !== null
|
||||
? (providers.file as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
if (
|
||||
`${fileProvider.directory ?? ""}`.trim() !== dynamicDirectory ||
|
||||
fileProvider.watch !== true
|
||||
) {
|
||||
parsed.providers = {
|
||||
...providers,
|
||||
file: {
|
||||
...fileProvider,
|
||||
directory: dynamicDirectory,
|
||||
watch: true,
|
||||
},
|
||||
};
|
||||
|
||||
await writePrivilegedFile(
|
||||
server,
|
||||
staticConfig.path,
|
||||
yaml.dump(parsed, { lineWidth: 120 }),
|
||||
);
|
||||
}
|
||||
|
||||
return dynamicDirectory;
|
||||
}
|
||||
|
||||
if (!/\[providers\.file\]/.test(staticConfig.content)) {
|
||||
const nextContent = `${staticConfig.content.trimEnd()}\n\n[providers.file]\n directory = "${dynamicDirectory}"\n watch = true\n`;
|
||||
await writePrivilegedFile(server, staticConfig.path, nextContent);
|
||||
}
|
||||
|
||||
return dynamicDirectory;
|
||||
}
|
||||
|
||||
function buildTraefikDynamicConfig(
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
): string {
|
||||
const key = sanitizeDomainFileName(domainName).replace(/\./g, "-");
|
||||
const parsed = new URL(upstream);
|
||||
|
||||
return yaml.dump(
|
||||
{
|
||||
http: {
|
||||
routers: {
|
||||
[`portainer-${key}`]: {
|
||||
rule: `Host(\`${domainName}\`)`,
|
||||
service: `portainer-${key}`,
|
||||
},
|
||||
},
|
||||
services: {
|
||||
[`portainer-${key}`]: {
|
||||
loadBalancer: {
|
||||
servers: [{ url: `${parsed.protocol}//${parsed.host}` }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ lineWidth: 120 },
|
||||
);
|
||||
}
|
||||
|
||||
async function reloadTraefik(server: Server): Promise<void> {
|
||||
const script = [
|
||||
"if command -v systemctl >/dev/null 2>&1 && systemctl status traefik >/dev/null 2>&1; then",
|
||||
" systemctl restart traefik",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"if command -v docker >/dev/null 2>&1; then",
|
||||
' TRAEFIK_ID=$(docker ps --format "{{.ID}}|{{.Names}}|{{.Image}}" | awk -F"|" \'BEGIN{IGNORECASE=1} /traefik/ {print $1; exit}\')',
|
||||
' if [ -n "$TRAEFIK_ID" ]; then',
|
||||
' docker restart "$TRAEFIK_ID" >/dev/null',
|
||||
" exit 0",
|
||||
" fi",
|
||||
"fi",
|
||||
"exit 0",
|
||||
].join("; ");
|
||||
|
||||
await execPrivileged(server, script);
|
||||
}
|
||||
|
||||
async function provisionTraefikConfig(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
): Promise<DomainProvisioningResult> {
|
||||
const dynamicDirectory = await ensureTraefikFileProvider(server);
|
||||
const configPath = `${dynamicDirectory}/${sanitizeDomainFileName(domainName)}.yml`;
|
||||
|
||||
await writePrivilegedFile(
|
||||
server,
|
||||
configPath,
|
||||
buildTraefikDynamicConfig(domainName, upstream),
|
||||
);
|
||||
await reloadTraefik(server);
|
||||
|
||||
return {
|
||||
upstream,
|
||||
configPath,
|
||||
reloadTarget: "traefik",
|
||||
};
|
||||
}
|
||||
|
||||
export async function provisionDomainProxyConfig(options: {
|
||||
server: Server;
|
||||
domainName: string;
|
||||
proxy: ProxyType;
|
||||
sslEnabled: boolean;
|
||||
container: Pick<Container, "id" | "name" | "dockerId" | "serverId">;
|
||||
targetPort: number;
|
||||
}): Promise<DomainProvisioningResult> {
|
||||
const upstreamTarget = await resolveContainerUpstream(
|
||||
options.server,
|
||||
options.container,
|
||||
options.targetPort,
|
||||
);
|
||||
|
||||
if (options.proxy === "NGINX") {
|
||||
return provisionNginxConfig(
|
||||
options.server,
|
||||
options.domainName,
|
||||
upstreamTarget.upstream,
|
||||
options.container.name,
|
||||
options.sslEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.proxy === "CADDY") {
|
||||
return provisionCaddyConfig(
|
||||
options.server,
|
||||
options.domainName,
|
||||
upstreamTarget.upstream,
|
||||
options.sslEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.proxy === "TRAEFIK") {
|
||||
return provisionTraefikConfig(
|
||||
options.server,
|
||||
options.domainName,
|
||||
upstreamTarget.upstream,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Proxy ${options.proxy} does not support automatic provisioning.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeManagedDomainProxyConfig(options: {
|
||||
server: Server;
|
||||
domainName: string;
|
||||
proxy: ProxyType;
|
||||
containerName?: string | null;
|
||||
}): Promise<void> {
|
||||
if (options.proxy === "NONE") {
|
||||
return;
|
||||
}
|
||||
|
||||
const domainSafeName = sanitizeDomainFileName(options.domainName);
|
||||
|
||||
if (options.proxy === "NGINX") {
|
||||
const managedSafeName = options.containerName
|
||||
? buildManagedNginxFileBase(options.containerName, options.domainName)
|
||||
: domainSafeName;
|
||||
const enabledPath = `/etc/nginx/sites-enabled/${managedSafeName}.conf`;
|
||||
const availablePath = `/etc/nginx/sites-available/${managedSafeName}.conf`;
|
||||
const previousEnabledPath = `/etc/nginx/sites-enabled/${domainSafeName}.conf`;
|
||||
const previousAvailablePath = `/etc/nginx/sites-available/${domainSafeName}.conf`;
|
||||
const legacyConfigPath = `/etc/nginx/conf.d/portainer-domains/${domainSafeName}.conf`;
|
||||
|
||||
await removePrivilegedFile(options.server, enabledPath);
|
||||
await removePrivilegedFile(options.server, availablePath);
|
||||
if (managedSafeName !== domainSafeName) {
|
||||
await removePrivilegedFile(options.server, previousEnabledPath);
|
||||
await removePrivilegedFile(options.server, previousAvailablePath);
|
||||
}
|
||||
await removePrivilegedFile(options.server, legacyConfigPath);
|
||||
await validateAndReloadNginx(options.server, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.proxy === "CADDY") {
|
||||
const mainConfigPath = (await pathExistsPrivileged(
|
||||
options.server,
|
||||
"/etc/caddy/Caddyfile",
|
||||
))
|
||||
? "/etc/caddy/Caddyfile"
|
||||
: "/usr/local/etc/caddy/Caddyfile";
|
||||
await removePrivilegedFile(
|
||||
options.server,
|
||||
`/etc/caddy/portainer-domains/${domainSafeName}.caddy`,
|
||||
);
|
||||
await execPrivileged(
|
||||
options.server,
|
||||
`caddy validate --config ${escapeDoubleQuotedShellArg(mainConfigPath)}`,
|
||||
);
|
||||
await execPrivileged(
|
||||
options.server,
|
||||
`caddy reload --config ${escapeDoubleQuotedShellArg(mainConfigPath)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await removePrivilegedFile(
|
||||
options.server,
|
||||
`/etc/traefik/dynamic/portainer/${domainSafeName}.yml`,
|
||||
);
|
||||
await reloadTraefik(options.server);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
// Backward-compatibility facade.
|
||||
//
|
||||
// Business logic domain provisioning sudah dipindahkan ke folder
|
||||
// `./domain-provisioning/*`. File asli sebelum modularisasi disimpan di
|
||||
// `./backup/domain-provisioning.service.ts`. Semua export publik tetap
|
||||
// dipertahankan lewat re-export di bawah ini.
|
||||
|
||||
export * from "./domain-provisioning";
|
||||
@@ -0,0 +1,23 @@
|
||||
# Domain Provisioning Modules
|
||||
|
||||
Folder ini berisi modularisasi business logic dari `domain-provisioning.service.ts`.
|
||||
|
||||
Struktur:
|
||||
|
||||
- `types.ts`: kontrak publik dan type internal utama.
|
||||
- `shell.ts`: helper shell command dan privileged execution.
|
||||
- `filesystem.ts`: helper baca/tulis/hapus file remote.
|
||||
- `names.ts`: helper nama file dan identifier config.
|
||||
- `container-upstream.ts`: resolusi port container dan upstream target.
|
||||
- `nginx.ts`: provisioning dan cleanup config Nginx.
|
||||
- `caddy.ts`: provisioning dan cleanup config Caddy.
|
||||
- `traefik.ts`: provisioning dan cleanup config Traefik.
|
||||
- `service.ts`: orchestration API publik untuk provisioning domain.
|
||||
- `index.ts`: public barrel.
|
||||
|
||||
Pattern:
|
||||
|
||||
- Helper umum dipisah dari provider-specific logic.
|
||||
- Setiap provider proxy punya file sendiri.
|
||||
- `service.ts` hanya mengorkestrasi pemilihan provider.
|
||||
- `../domain-provisioning.service.ts` dipertahankan sebagai compatibility facade.
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import {
|
||||
readPrivilegedFile,
|
||||
removePrivilegedFile,
|
||||
pathExistsPrivileged,
|
||||
writePrivilegedFile,
|
||||
} from "./filesystem";
|
||||
import { sanitizeDomainFileName } from "./names";
|
||||
import { escapeDoubleQuotedShellArg, execPrivileged } from "./shell";
|
||||
import { DomainProvisioningResult } from "./types";
|
||||
|
||||
function buildCaddyConfig(
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
sslEnabled: boolean,
|
||||
): string {
|
||||
const siteLabel = sslEnabled ? domainName : `http://${domainName}`;
|
||||
return [
|
||||
`${siteLabel} {`,
|
||||
` reverse_proxy ${upstream.replace(/^https?:\/\//, "")}`,
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function provisionCaddyConfig(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
sslEnabled: boolean,
|
||||
): Promise<DomainProvisioningResult> {
|
||||
const mainConfigCandidates = [
|
||||
"/etc/caddy/Caddyfile",
|
||||
"/usr/local/etc/caddy/Caddyfile",
|
||||
];
|
||||
|
||||
let mainConfigPath: string | null = null;
|
||||
let mainConfigContent: string | null = null;
|
||||
for (const candidate of mainConfigCandidates) {
|
||||
const content = await readPrivilegedFile(server, candidate);
|
||||
if (content !== null) {
|
||||
mainConfigPath = candidate;
|
||||
mainConfigContent = content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mainConfigPath || mainConfigContent === null) {
|
||||
throw new Error(
|
||||
`Caddyfile was not found on ${server.name}. Expected one of: ${mainConfigCandidates.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const importLine = "import /etc/caddy/portainer-domains/*.caddy";
|
||||
if (!mainConfigContent.includes(importLine)) {
|
||||
const nextContent = `${mainConfigContent.trimEnd()}\n\n${importLine}\n`;
|
||||
await writePrivilegedFile(server, mainConfigPath, nextContent);
|
||||
}
|
||||
|
||||
const configPath = `/etc/caddy/portainer-domains/${sanitizeDomainFileName(domainName)}.caddy`;
|
||||
await writePrivilegedFile(
|
||||
server,
|
||||
configPath,
|
||||
buildCaddyConfig(domainName, upstream, sslEnabled),
|
||||
);
|
||||
await execPrivileged(
|
||||
server,
|
||||
`caddy validate --config ${escapeDoubleQuotedShellArg(mainConfigPath)}`,
|
||||
);
|
||||
await execPrivileged(
|
||||
server,
|
||||
`caddy reload --config ${escapeDoubleQuotedShellArg(mainConfigPath)}`,
|
||||
);
|
||||
|
||||
return {
|
||||
upstream,
|
||||
configPath,
|
||||
reloadTarget: "caddy",
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeManagedCaddyProxyConfig(options: {
|
||||
server: Server;
|
||||
domainName: string;
|
||||
}): Promise<void> {
|
||||
const domainSafeName = sanitizeDomainFileName(options.domainName);
|
||||
const mainConfigPath = (await pathExistsPrivileged(
|
||||
options.server,
|
||||
"/etc/caddy/Caddyfile",
|
||||
))
|
||||
? "/etc/caddy/Caddyfile"
|
||||
: "/usr/local/etc/caddy/Caddyfile";
|
||||
|
||||
await removePrivilegedFile(
|
||||
options.server,
|
||||
`/etc/caddy/portainer-domains/${domainSafeName}.caddy`,
|
||||
);
|
||||
await execPrivileged(
|
||||
options.server,
|
||||
`caddy validate --config ${escapeDoubleQuotedShellArg(mainConfigPath)}`,
|
||||
);
|
||||
await execPrivileged(
|
||||
options.server,
|
||||
`caddy reload --config ${escapeDoubleQuotedShellArg(mainConfigPath)}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Container, Server } from "@prisma/client";
|
||||
import { DockerContainerInspect, dockerInspect } from "../ssh.service";
|
||||
import { ContainerPortBinding, ContainerUpstreamTarget } from "./types";
|
||||
|
||||
function getContainerNetworkIp(inspect: DockerContainerInspect): string | null {
|
||||
const networkSettings = inspect.NetworkSettings as
|
||||
| {
|
||||
Networks?: Record<string, { IPAddress?: string }>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const networks = networkSettings?.Networks ?? {};
|
||||
for (const network of Object.values(networks)) {
|
||||
const ipAddress = `${network?.IPAddress ?? ""}`.trim();
|
||||
if (ipAddress) {
|
||||
return ipAddress;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getContainerPortBindings(
|
||||
inspect: DockerContainerInspect,
|
||||
): ContainerPortBinding[] {
|
||||
const networkSettings = inspect.NetworkSettings as
|
||||
| {
|
||||
Ports?: Record<string, Array<{ HostPort?: string }> | null>;
|
||||
}
|
||||
| undefined;
|
||||
const config = inspect.Config as
|
||||
| {
|
||||
ExposedPorts?: Record<string, Record<string, never>>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const bindings = new Map<string, ContainerPortBinding>();
|
||||
|
||||
for (const [rawPort, hosts] of Object.entries(networkSettings?.Ports ?? {})) {
|
||||
const [portRaw, protocolRaw] = rawPort.split("/");
|
||||
const containerPort = Number(portRaw);
|
||||
const protocol = protocolRaw || "tcp";
|
||||
if (!Number.isFinite(containerPort)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hosts || hosts.length === 0) {
|
||||
bindings.set(rawPort, {
|
||||
containerPort,
|
||||
protocol,
|
||||
hostPort: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const host of hosts) {
|
||||
const hostPort = Number(host?.HostPort ?? "");
|
||||
bindings.set(`${rawPort}:${host?.HostPort ?? "internal"}`, {
|
||||
containerPort,
|
||||
protocol,
|
||||
hostPort: Number.isFinite(hostPort) ? hostPort : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const rawPort of Object.keys(config?.ExposedPorts ?? {})) {
|
||||
const [portRaw, protocolRaw] = rawPort.split("/");
|
||||
const containerPort = Number(portRaw);
|
||||
const protocol = protocolRaw || "tcp";
|
||||
if (!Number.isFinite(containerPort)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = Array.from(bindings.values()).some(
|
||||
(binding) =>
|
||||
binding.containerPort === containerPort &&
|
||||
binding.protocol === protocol,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
bindings.set(rawPort, {
|
||||
containerPort,
|
||||
protocol,
|
||||
hostPort: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(bindings.values()).sort(
|
||||
(left, right) => left.containerPort - right.containerPort,
|
||||
);
|
||||
}
|
||||
|
||||
export function describeContainerTargetPorts(
|
||||
inspect: DockerContainerInspect,
|
||||
): Array<{
|
||||
containerPort: number;
|
||||
hostPort: number | null;
|
||||
protocol: string;
|
||||
label: string;
|
||||
}> {
|
||||
return getContainerPortBindings(inspect).map((binding) => ({
|
||||
...binding,
|
||||
label: binding.hostPort
|
||||
? `${binding.containerPort}/${binding.protocol} (published as ${binding.hostPort})`
|
||||
: `${binding.containerPort}/${binding.protocol} (container internal)`,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function resolveContainerUpstream(
|
||||
server: Server,
|
||||
container: Pick<Container, "id" | "name" | "dockerId" | "serverId">,
|
||||
targetPort: number,
|
||||
): Promise<ContainerUpstreamTarget> {
|
||||
const inspect = await dockerInspect(
|
||||
server,
|
||||
container.dockerId || container.name,
|
||||
);
|
||||
const bindings = getContainerPortBindings(inspect);
|
||||
const matching = bindings.find(
|
||||
(binding) => binding.containerPort === targetPort,
|
||||
);
|
||||
|
||||
if (!matching) {
|
||||
throw new Error(
|
||||
`Container ${container.name} does not expose port ${targetPort}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (matching.hostPort) {
|
||||
return {
|
||||
upstream: `http://127.0.0.1:${matching.hostPort}`,
|
||||
selectedPort: targetPort,
|
||||
};
|
||||
}
|
||||
|
||||
const containerIp = getContainerNetworkIp(inspect);
|
||||
if (!containerIp) {
|
||||
throw new Error(
|
||||
`Container ${container.name} has no reachable IP address for internal port ${targetPort}. Publish the port first or connect it to a reachable Docker network.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
upstream: `http://${containerIp}:${targetPort}`,
|
||||
selectedPort: targetPort,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import { exec } from "../ssh.service";
|
||||
import {
|
||||
escapeDoubleQuotedShellArg,
|
||||
execPrivileged,
|
||||
execPrivilegedLoose,
|
||||
shellCommand,
|
||||
} from "./shell";
|
||||
|
||||
export async function pathExists(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
): Promise<boolean> {
|
||||
const result = await exec(
|
||||
server,
|
||||
shellCommand(
|
||||
`[ -e ${escapeDoubleQuotedShellArg(filePath)} ] && printf yes || printf no`,
|
||||
),
|
||||
);
|
||||
|
||||
return (result.stdout || "").trim() === "yes";
|
||||
}
|
||||
|
||||
export async function pathExistsPrivileged(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
): Promise<boolean> {
|
||||
const result = await execPrivilegedLoose(
|
||||
server,
|
||||
`[ -e ${escapeDoubleQuotedShellArg(filePath)} ] && printf yes || printf no`,
|
||||
);
|
||||
|
||||
return (result.stdout || "").trim() === "yes";
|
||||
}
|
||||
|
||||
export async function readPrivilegedFile(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
): Promise<string | null> {
|
||||
if (!(await pathExistsPrivileged(server, filePath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return execPrivileged(server, `cat ${escapeDoubleQuotedShellArg(filePath)}`);
|
||||
}
|
||||
|
||||
export async function writePrivilegedFile(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const encoded = Buffer.from(content, "utf8").toString("base64");
|
||||
const parentDir = filePath.replace(/\/[^/]+$/, "") || "/";
|
||||
|
||||
await execPrivileged(
|
||||
server,
|
||||
[
|
||||
`mkdir -p ${escapeDoubleQuotedShellArg(parentDir)}`,
|
||||
`printf %s ${escapeDoubleQuotedShellArg(encoded)} | base64 -d > ${escapeDoubleQuotedShellArg(filePath)}`,
|
||||
].join(" && "),
|
||||
);
|
||||
}
|
||||
|
||||
export async function removePrivilegedFile(
|
||||
server: Server,
|
||||
filePath: string,
|
||||
): Promise<void> {
|
||||
await execPrivileged(server, `rm -f ${escapeDoubleQuotedShellArg(filePath)}`);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./types";
|
||||
export * from "./service";
|
||||
@@ -0,0 +1,57 @@
|
||||
export function sanitizeDomainFileName(domainName: string): string {
|
||||
return domainName.toLowerCase().replace(/[^a-z0-9.-]/g, "-");
|
||||
}
|
||||
|
||||
export function sanitizeContainerFileName(containerName: string): string {
|
||||
return containerName.toLowerCase().replace(/[^a-z0-9.-]/g, "-");
|
||||
}
|
||||
|
||||
export function buildManagedNginxFileBase(
|
||||
containerName: string,
|
||||
domainName: string,
|
||||
): string {
|
||||
void containerName;
|
||||
return sanitizeDomainFileName(domainName);
|
||||
}
|
||||
|
||||
function normalizeAnchorDomain(domainName: string): string {
|
||||
return domainName.trim().toLowerCase().replace(/^\*\./, "");
|
||||
}
|
||||
|
||||
export function getDomainConfigAnchor(domainNames: string[]): string {
|
||||
const normalized = Array.from(
|
||||
new Set(domainNames.map(normalizeAnchorDomain).filter(Boolean)),
|
||||
);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return "shared-domain";
|
||||
}
|
||||
|
||||
normalized.sort((left, right) => {
|
||||
const leftLabels = left.split(".").length;
|
||||
const rightLabels = right.split(".").length;
|
||||
|
||||
if (leftLabels !== rightLabels) {
|
||||
return leftLabels - rightLabels;
|
||||
}
|
||||
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
|
||||
const candidate = normalized[0] ?? "shared-domain";
|
||||
const labels = candidate.split(".").filter(Boolean);
|
||||
|
||||
if (labels.length >= 2) {
|
||||
return labels.slice(-2).join(".");
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function buildManagedNginxSharedFileBase(
|
||||
containerName: string,
|
||||
domainNames: string[],
|
||||
): string {
|
||||
void containerName;
|
||||
return sanitizeDomainFileName(getDomainConfigAnchor(domainNames));
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import { resolveSslCertificate, restartManagedService } from "../ssh.service";
|
||||
import {
|
||||
pathExistsPrivileged,
|
||||
readPrivilegedFile,
|
||||
removePrivilegedFile,
|
||||
writePrivilegedFile,
|
||||
} from "./filesystem";
|
||||
import {
|
||||
buildManagedNginxFileBase,
|
||||
buildManagedNginxSharedFileBase,
|
||||
getDomainConfigAnchor,
|
||||
sanitizeDomainFileName,
|
||||
sanitizeContainerFileName,
|
||||
} from "./names";
|
||||
import {
|
||||
escapeDoubleQuotedShellArg,
|
||||
execPrivileged,
|
||||
execPrivilegedLoose,
|
||||
} from "./shell";
|
||||
import { DomainProvisioningError, DomainProvisioningResult } from "./types";
|
||||
|
||||
function findHttpBlockEnd(configContent: string): number | null {
|
||||
const httpMatch = /\bhttp\s*\{/.exec(configContent);
|
||||
if (!httpMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const braceStart = configContent.indexOf("{", httpMatch.index);
|
||||
if (braceStart < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
for (let index = braceStart; index < configContent.length; index += 1) {
|
||||
const char = configContent[index];
|
||||
if (char === "{") {
|
||||
depth += 1;
|
||||
} else if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureNginxSitesEnabledInclude(configContent: string): string {
|
||||
if (
|
||||
/include\s+\/etc\/nginx\/sites-enabled\/\*\.conf\s*;/.test(configContent)
|
||||
) {
|
||||
return configContent;
|
||||
}
|
||||
|
||||
const httpBlockEnd = findHttpBlockEnd(configContent);
|
||||
if (httpBlockEnd === null) {
|
||||
throw new DomainProvisioningError(
|
||||
"Could not find the http block in nginx.conf to register /etc/nginx/sites-enabled/*.conf.",
|
||||
);
|
||||
}
|
||||
|
||||
const includeLine = "\n include /etc/nginx/sites-enabled/*.conf;\n";
|
||||
return `${configContent.slice(0, httpBlockEnd)}${includeLine}${configContent.slice(httpBlockEnd)}`;
|
||||
}
|
||||
|
||||
function normalizeNginxFailureMessage(message: string): string {
|
||||
if (
|
||||
/socket\(\) \[::\]:80 failed \(97: Address family not supported by protocol\)/i.test(
|
||||
message,
|
||||
)
|
||||
) {
|
||||
return [
|
||||
"Nginx validation failed on the target server because an IPv6 listener like `listen [::]:80` is enabled, but the host does not support IPv6.",
|
||||
"Adjust the existing Nginx configuration on that server to remove or conditionalize the IPv6 listener, then retry Add Domain.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function buildNginxProxyLocation(upstream: string): string[] {
|
||||
return [
|
||||
" location / {",
|
||||
` proxy_pass ${upstream};`,
|
||||
" proxy_http_version 1.1;",
|
||||
" proxy_set_header Host $host;",
|
||||
" proxy_set_header X-Real-IP $remote_addr;",
|
||||
" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;",
|
||||
" proxy_set_header X-Forwarded-Proto $scheme;",
|
||||
" proxy_set_header Upgrade $http_upgrade;",
|
||||
' proxy_set_header Connection "upgrade";',
|
||||
" }",
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeDomainSet(domainNames: string[]): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
domainNames.map((name) => name.trim().toLowerCase()).filter(Boolean),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildServerNameValue(domainNames: string[]): string {
|
||||
return normalizeDomainSet(domainNames).join(" ");
|
||||
}
|
||||
|
||||
type NginxServerEntry = {
|
||||
domainName: string;
|
||||
upstream: string;
|
||||
};
|
||||
|
||||
function normalizeNginxServerEntries(
|
||||
entries: NginxServerEntry[] | undefined,
|
||||
fallbackDomainNames: string[],
|
||||
fallbackUpstream: string,
|
||||
): NginxServerEntry[] {
|
||||
const normalized = new Map<string, NginxServerEntry>();
|
||||
|
||||
for (const entry of entries ?? []) {
|
||||
const domainName = entry.domainName.trim().toLowerCase();
|
||||
const upstream = entry.upstream.trim();
|
||||
if (domainName && upstream) {
|
||||
normalized.set(domainName, { domainName, upstream });
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.size === 0) {
|
||||
for (const domainName of fallbackDomainNames) {
|
||||
normalized.set(domainName, { domainName, upstream: fallbackUpstream });
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(normalized.values()).sort((left, right) =>
|
||||
left.domainName.localeCompare(right.domainName),
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveSharedSslCertificateName(options: {
|
||||
server: Server;
|
||||
domainNames: string[];
|
||||
primaryDomainName: string;
|
||||
sslEnabled: boolean;
|
||||
}): Promise<string | null> {
|
||||
if (!options.sslEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const exactDomains = normalizeDomainSet(options.domainNames).filter(
|
||||
(domainName) => !domainName.startsWith("*."),
|
||||
);
|
||||
if (exactDomains.length !== options.domainNames.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const certificate = await resolveSslCertificate(
|
||||
options.server,
|
||||
options.primaryDomainName,
|
||||
).catch(() => null);
|
||||
if (!certificate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const certificateDomains = new Set(
|
||||
certificate.domainNames.map((domainName) => domainName.toLowerCase()),
|
||||
);
|
||||
|
||||
return exactDomains.every((domainName) => certificateDomains.has(domainName))
|
||||
? certificate.certName
|
||||
: null;
|
||||
}
|
||||
|
||||
function buildNginxConfig(
|
||||
domainNames: string[],
|
||||
upstream: string,
|
||||
configMode: "SHARED" | "ISOLATED",
|
||||
primaryDomainName: string,
|
||||
sslCertName?: string | null,
|
||||
serverEntries?: NginxServerEntry[],
|
||||
): string {
|
||||
const serverNameValue = buildServerNameValue(domainNames);
|
||||
const entries = normalizeNginxServerEntries(
|
||||
serverEntries,
|
||||
domainNames,
|
||||
upstream,
|
||||
);
|
||||
|
||||
if (!sslCertName) {
|
||||
const blocks =
|
||||
configMode === "SHARED"
|
||||
? entries.flatMap((entry) => [
|
||||
"server {",
|
||||
" listen 80;",
|
||||
` server_name ${entry.domainName};`,
|
||||
"",
|
||||
...buildNginxProxyLocation(entry.upstream),
|
||||
"}",
|
||||
"",
|
||||
])
|
||||
: [
|
||||
"server {",
|
||||
" listen 80;",
|
||||
` server_name ${serverNameValue};`,
|
||||
"",
|
||||
...buildNginxProxyLocation(upstream),
|
||||
"}",
|
||||
"",
|
||||
];
|
||||
|
||||
return [
|
||||
"# doktainer-managed: true",
|
||||
`# doktainer-config-mode: ${configMode}`,
|
||||
`# doktainer-primary-domain: ${primaryDomainName}`,
|
||||
...blocks,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const certPath = `/etc/letsencrypt/live/${sslCertName}/fullchain.pem`;
|
||||
const keyPath = `/etc/letsencrypt/live/${sslCertName}/privkey.pem`;
|
||||
|
||||
const httpsBlocks =
|
||||
configMode === "SHARED"
|
||||
? entries.flatMap((entry) => [
|
||||
"server {",
|
||||
" listen 443 ssl http2;",
|
||||
` server_name ${entry.domainName};`,
|
||||
` ssl_certificate ${certPath};`,
|
||||
` ssl_certificate_key ${keyPath};`,
|
||||
" ssl_session_cache shared:SSL:10m;",
|
||||
" ssl_session_timeout 10m;",
|
||||
" ssl_protocols TLSv1.2 TLSv1.3;",
|
||||
" ssl_prefer_server_ciphers off;",
|
||||
"",
|
||||
...buildNginxProxyLocation(entry.upstream),
|
||||
"}",
|
||||
"",
|
||||
])
|
||||
: [
|
||||
"server {",
|
||||
" listen 443 ssl http2;",
|
||||
` server_name ${serverNameValue};`,
|
||||
` ssl_certificate ${certPath};`,
|
||||
` ssl_certificate_key ${keyPath};`,
|
||||
" ssl_session_cache shared:SSL:10m;",
|
||||
" ssl_session_timeout 10m;",
|
||||
" ssl_protocols TLSv1.2 TLSv1.3;",
|
||||
" ssl_prefer_server_ciphers off;",
|
||||
"",
|
||||
...buildNginxProxyLocation(upstream),
|
||||
"}",
|
||||
"",
|
||||
];
|
||||
|
||||
return [
|
||||
"# doktainer-managed: true",
|
||||
`# doktainer-config-mode: ${configMode}`,
|
||||
`# doktainer-primary-domain: ${primaryDomainName}`,
|
||||
"server {",
|
||||
" listen 80;",
|
||||
` server_name ${serverNameValue};`,
|
||||
" return 301 https://$host$request_uri;",
|
||||
"}",
|
||||
"",
|
||||
...httpsBlocks,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function detectNginxMainConfigPath(server: Server): Promise<string> {
|
||||
const candidates = new Set([
|
||||
"/etc/nginx/nginx.conf",
|
||||
"/etc/openresty/nginx.conf",
|
||||
"/usr/local/nginx/conf/nginx.conf",
|
||||
"/usr/local/openresty/nginx/conf/nginx.conf",
|
||||
"/usr/local/etc/nginx/nginx.conf",
|
||||
"/opt/nginx/conf/nginx.conf",
|
||||
"/www/server/nginx/conf/nginx.conf",
|
||||
]);
|
||||
|
||||
const versionOutput = await execPrivilegedLoose(
|
||||
server,
|
||||
"nginx -V 2>&1 || true",
|
||||
);
|
||||
const combinedVersionOutput = `${versionOutput.stdout}\n${versionOutput.stderr}`;
|
||||
const confPathMatch = combinedVersionOutput.match(/--conf-path=([^\s]+)/);
|
||||
if (confPathMatch?.[1]) {
|
||||
candidates.add(confPathMatch[1].trim());
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (await pathExistsPrivileged(server, candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const discoveryResult = await execPrivilegedLoose(
|
||||
server,
|
||||
[
|
||||
"for root in /etc /usr/local /opt /www /home /srv; do",
|
||||
' if [ -d "$root" ]; then',
|
||||
' found=$(find -L "$root" -maxdepth 6 -type f -name nginx.conf 2>/dev/null | head -n 1);',
|
||||
' if [ -n "$found" ]; then printf "%s\\n" "$found"; exit 0; fi',
|
||||
" fi",
|
||||
"done",
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
const discoveredPath = `${discoveryResult.stdout || ""}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
|
||||
if (discoveredPath && (await pathExistsPrivileged(server, discoveredPath))) {
|
||||
return discoveredPath;
|
||||
}
|
||||
|
||||
throw new DomainProvisioningError(
|
||||
`nginx.conf was not found on ${server.name}. Checked Nginx --conf-path and common locations: ${Array.from(candidates).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureNginxSitesEnabledRegistration(
|
||||
server: Server,
|
||||
): Promise<void> {
|
||||
const mainConfigPath = await detectNginxMainConfigPath(server);
|
||||
const currentContent = await readPrivilegedFile(server, mainConfigPath);
|
||||
|
||||
if (currentContent === null) {
|
||||
throw new DomainProvisioningError(
|
||||
`Unable to read Nginx main config at ${mainConfigPath}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextContent = ensureNginxSitesEnabledInclude(currentContent);
|
||||
if (nextContent !== currentContent) {
|
||||
await writePrivilegedFile(server, mainConfigPath, nextContent);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAndReloadNginx(
|
||||
server: Server,
|
||||
rollbackPaths: string[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
await execPrivileged(server, "nginx -t");
|
||||
await restartManagedService(server, "nginx");
|
||||
} catch (error) {
|
||||
for (const rollbackPath of rollbackPaths) {
|
||||
await removePrivilegedFile(server, rollbackPath).catch(() => undefined);
|
||||
}
|
||||
|
||||
throw new DomainProvisioningError(
|
||||
normalizeNginxFailureMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function provisionNginxConfig(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
containerName: string,
|
||||
sslEnabled: boolean,
|
||||
options?: {
|
||||
domainNames?: string[];
|
||||
serverEntries?: NginxServerEntry[];
|
||||
configMode?: "SHARED" | "ISOLATED";
|
||||
primaryDomainName?: string | null;
|
||||
},
|
||||
): Promise<DomainProvisioningResult> {
|
||||
const configMode = options?.configMode ?? "ISOLATED";
|
||||
const domainNames = normalizeDomainSet(options?.domainNames ?? [domainName]);
|
||||
const primaryDomainName =
|
||||
options?.primaryDomainName?.trim().toLowerCase() ||
|
||||
domainNames[0] ||
|
||||
domainName;
|
||||
const safeName =
|
||||
configMode === "SHARED"
|
||||
? buildManagedNginxSharedFileBase(containerName, domainNames)
|
||||
: buildManagedNginxFileBase(containerName, domainName);
|
||||
const legacySharedSafeName =
|
||||
configMode === "SHARED"
|
||||
? `${sanitizeContainerFileName(containerName)}--${sanitizeDomainFileName(getDomainConfigAnchor(domainNames))}`
|
||||
: null;
|
||||
const legacyServiceSharedSafeName =
|
||||
configMode === "SHARED"
|
||||
? `doktainer-${sanitizeContainerFileName(containerName)}`
|
||||
: null;
|
||||
const configPath = `/etc/nginx/sites-available/${safeName}.conf`;
|
||||
const enabledPath = `/etc/nginx/sites-enabled/${safeName}.conf`;
|
||||
const cleanupCommands = domainNames.flatMap((managedDomainName) => {
|
||||
const managedDomainSafeName = sanitizeDomainFileName(managedDomainName);
|
||||
const legacyConfigPath = `/etc/nginx/conf.d/portainer-domains/${managedDomainSafeName}.conf`;
|
||||
const previousConfigPath = `/etc/nginx/sites-available/${managedDomainSafeName}.conf`;
|
||||
const previousEnabledPath = `/etc/nginx/sites-enabled/${managedDomainSafeName}.conf`;
|
||||
const isolatedBase = buildManagedNginxFileBase(
|
||||
containerName,
|
||||
managedDomainName,
|
||||
);
|
||||
const isolatedAvailablePath = `/etc/nginx/sites-available/${isolatedBase}.conf`;
|
||||
const isolatedEnabledPath = `/etc/nginx/sites-enabled/${isolatedBase}.conf`;
|
||||
|
||||
const commands = [`rm -f ${escapeDoubleQuotedShellArg(legacyConfigPath)}`];
|
||||
|
||||
if (managedDomainSafeName !== safeName) {
|
||||
commands.push(
|
||||
`rm -f ${escapeDoubleQuotedShellArg(previousEnabledPath)} ${escapeDoubleQuotedShellArg(previousConfigPath)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (isolatedBase !== safeName) {
|
||||
commands.push(
|
||||
`rm -f ${escapeDoubleQuotedShellArg(isolatedEnabledPath)} ${escapeDoubleQuotedShellArg(isolatedAvailablePath)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return commands;
|
||||
});
|
||||
|
||||
if (legacySharedSafeName && legacySharedSafeName !== safeName) {
|
||||
cleanupCommands.push(
|
||||
`rm -f ${escapeDoubleQuotedShellArg(`/etc/nginx/sites-enabled/${legacySharedSafeName}.conf`)} ${escapeDoubleQuotedShellArg(`/etc/nginx/sites-available/${legacySharedSafeName}.conf`)}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
legacyServiceSharedSafeName &&
|
||||
legacyServiceSharedSafeName !== safeName
|
||||
) {
|
||||
cleanupCommands.push(
|
||||
`rm -f ${escapeDoubleQuotedShellArg(`/etc/nginx/sites-enabled/${legacyServiceSharedSafeName}.conf`)} ${escapeDoubleQuotedShellArg(`/etc/nginx/sites-available/${legacyServiceSharedSafeName}.conf`)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await ensureNginxSitesEnabledRegistration(server);
|
||||
const sslCertificateName =
|
||||
configMode === "SHARED"
|
||||
? await resolveSharedSslCertificateName({
|
||||
server,
|
||||
domainNames,
|
||||
primaryDomainName,
|
||||
sslEnabled,
|
||||
})
|
||||
: sslEnabled
|
||||
? ((await resolveSslCertificate(server, domainName).catch(() => null))
|
||||
?.certName ?? null)
|
||||
: null;
|
||||
await writePrivilegedFile(
|
||||
server,
|
||||
configPath,
|
||||
buildNginxConfig(
|
||||
domainNames,
|
||||
upstream,
|
||||
configMode,
|
||||
primaryDomainName,
|
||||
sslCertificateName,
|
||||
options?.serverEntries,
|
||||
),
|
||||
);
|
||||
await execPrivileged(
|
||||
server,
|
||||
[
|
||||
"mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled",
|
||||
`ln -sfn ${escapeDoubleQuotedShellArg(configPath)} ${escapeDoubleQuotedShellArg(enabledPath)}`,
|
||||
...cleanupCommands,
|
||||
].join(" && "),
|
||||
);
|
||||
await validateAndReloadNginx(server, [enabledPath, configPath]);
|
||||
|
||||
return {
|
||||
upstream,
|
||||
configPath,
|
||||
reloadTarget: "nginx",
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeManagedNginxProxyConfig(options: {
|
||||
server: Server;
|
||||
domainName: string;
|
||||
containerName?: string | null;
|
||||
domainNames?: string[];
|
||||
configMode?: "SHARED" | "ISOLATED";
|
||||
}): Promise<void> {
|
||||
const configMode = options.configMode ?? "ISOLATED";
|
||||
const domainNames = normalizeDomainSet(
|
||||
options.domainNames ?? [options.domainName],
|
||||
);
|
||||
const domainSafeName = sanitizeDomainFileName(options.domainName);
|
||||
const managedSafeName =
|
||||
options.containerName && configMode === "SHARED"
|
||||
? buildManagedNginxSharedFileBase(options.containerName, domainNames)
|
||||
: options.containerName
|
||||
? buildManagedNginxFileBase(options.containerName, options.domainName)
|
||||
: domainSafeName;
|
||||
const legacySharedSafeName =
|
||||
options.containerName && configMode === "SHARED"
|
||||
? `${sanitizeContainerFileName(options.containerName)}--${sanitizeDomainFileName(getDomainConfigAnchor(domainNames))}`
|
||||
: null;
|
||||
const legacyServiceSharedSafeName =
|
||||
options.containerName && configMode === "SHARED"
|
||||
? `doktainer-${sanitizeContainerFileName(options.containerName)}`
|
||||
: null;
|
||||
const enabledPath = `/etc/nginx/sites-enabled/${managedSafeName}.conf`;
|
||||
const availablePath = `/etc/nginx/sites-available/${managedSafeName}.conf`;
|
||||
const previousEnabledPath = `/etc/nginx/sites-enabled/${domainSafeName}.conf`;
|
||||
const previousAvailablePath = `/etc/nginx/sites-available/${domainSafeName}.conf`;
|
||||
const legacyConfigPath = `/etc/nginx/conf.d/portainer-domains/${domainSafeName}.conf`;
|
||||
|
||||
await removePrivilegedFile(options.server, enabledPath);
|
||||
await removePrivilegedFile(options.server, availablePath);
|
||||
if (legacySharedSafeName && legacySharedSafeName !== managedSafeName) {
|
||||
await removePrivilegedFile(
|
||||
options.server,
|
||||
`/etc/nginx/sites-enabled/${legacySharedSafeName}.conf`,
|
||||
);
|
||||
await removePrivilegedFile(
|
||||
options.server,
|
||||
`/etc/nginx/sites-available/${legacySharedSafeName}.conf`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
legacyServiceSharedSafeName &&
|
||||
legacyServiceSharedSafeName !== managedSafeName
|
||||
) {
|
||||
await removePrivilegedFile(
|
||||
options.server,
|
||||
`/etc/nginx/sites-enabled/${legacyServiceSharedSafeName}.conf`,
|
||||
);
|
||||
await removePrivilegedFile(
|
||||
options.server,
|
||||
`/etc/nginx/sites-available/${legacyServiceSharedSafeName}.conf`,
|
||||
);
|
||||
}
|
||||
if (managedSafeName !== domainSafeName) {
|
||||
await removePrivilegedFile(options.server, previousEnabledPath);
|
||||
await removePrivilegedFile(options.server, previousAvailablePath);
|
||||
}
|
||||
await removePrivilegedFile(options.server, legacyConfigPath);
|
||||
|
||||
await execPrivileged(
|
||||
options.server,
|
||||
[
|
||||
`[ -d /etc/nginx/sites-enabled ] && find /etc/nginx/sites-enabled -maxdepth 1 -type f -name ${escapeDoubleQuotedShellArg(`*--${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/sites-enabled ] && find /etc/nginx/sites-enabled -maxdepth 1 -type l -name ${escapeDoubleQuotedShellArg(`*--${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/sites-enabled ] && find /etc/nginx/sites-enabled -maxdepth 1 -type f -name ${escapeDoubleQuotedShellArg(`${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/sites-enabled ] && find /etc/nginx/sites-enabled -maxdepth 1 -type l -name ${escapeDoubleQuotedShellArg(`${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/sites-available ] && find /etc/nginx/sites-available -maxdepth 1 -type f -name ${escapeDoubleQuotedShellArg(`*--${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/sites-available ] && find /etc/nginx/sites-available -maxdepth 1 -type l -name ${escapeDoubleQuotedShellArg(`*--${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/sites-available ] && find /etc/nginx/sites-available -maxdepth 1 -type f -name ${escapeDoubleQuotedShellArg(`${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/sites-available ] && find /etc/nginx/sites-available -maxdepth 1 -type l -name ${escapeDoubleQuotedShellArg(`${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/conf.d/portainer-domains ] && find /etc/nginx/conf.d/portainer-domains -maxdepth 1 -type f -name ${escapeDoubleQuotedShellArg(`${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
`[ -d /etc/nginx/conf.d/portainer-domains ] && find /etc/nginx/conf.d/portainer-domains -maxdepth 1 -type l -name ${escapeDoubleQuotedShellArg(`${domainSafeName}.conf`)} -delete 2>/dev/null || true`,
|
||||
].join(" && "),
|
||||
);
|
||||
|
||||
await validateAndReloadNginx(options.server, []);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { provisionCaddyConfig, removeManagedCaddyProxyConfig } from "./caddy";
|
||||
import {
|
||||
describeContainerTargetPorts,
|
||||
resolveContainerUpstream,
|
||||
} from "./container-upstream";
|
||||
import { provisionNginxConfig, removeManagedNginxProxyConfig } from "./nginx";
|
||||
import {
|
||||
ProvisionDomainProxyOptions,
|
||||
RemoveManagedDomainProxyOptions,
|
||||
} from "./types";
|
||||
import {
|
||||
provisionTraefikConfig,
|
||||
removeManagedTraefikProxyConfig,
|
||||
} from "./traefik";
|
||||
|
||||
export { describeContainerTargetPorts, resolveContainerUpstream };
|
||||
|
||||
export async function provisionDomainProxyConfig(
|
||||
options: ProvisionDomainProxyOptions,
|
||||
) {
|
||||
const upstreamTarget = await resolveContainerUpstream(
|
||||
options.server,
|
||||
options.container,
|
||||
options.targetPort,
|
||||
);
|
||||
|
||||
if (options.proxy === "NGINX") {
|
||||
return provisionNginxConfig(
|
||||
options.server,
|
||||
options.domainName,
|
||||
upstreamTarget.upstream,
|
||||
options.container.name,
|
||||
options.sslEnabled,
|
||||
{
|
||||
domainNames: options.domainNames,
|
||||
serverEntries: options.nginxServerEntries,
|
||||
configMode: options.configMode,
|
||||
primaryDomainName: options.primaryDomainName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (options.proxy === "CADDY") {
|
||||
return provisionCaddyConfig(
|
||||
options.server,
|
||||
options.domainName,
|
||||
upstreamTarget.upstream,
|
||||
options.sslEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.proxy === "TRAEFIK") {
|
||||
return provisionTraefikConfig(
|
||||
options.server,
|
||||
options.domainName,
|
||||
upstreamTarget.upstream,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Proxy ${options.proxy} does not support automatic provisioning.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeManagedDomainProxyConfig(
|
||||
options: RemoveManagedDomainProxyOptions,
|
||||
): Promise<void> {
|
||||
if (options.proxy === "NONE") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.proxy === "NGINX") {
|
||||
await removeManagedNginxProxyConfig({
|
||||
server: options.server,
|
||||
domainName: options.domainName,
|
||||
containerName: options.containerName,
|
||||
domainNames: options.domainNames,
|
||||
configMode: options.configMode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.proxy === "CADDY") {
|
||||
await removeManagedCaddyProxyConfig({
|
||||
server: options.server,
|
||||
domainName: options.domainName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await removeManagedTraefikProxyConfig({
|
||||
server: options.server,
|
||||
domainName: options.domainName,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import { exec } from "../ssh.service";
|
||||
|
||||
export function escapeShellArg(value: string): string {
|
||||
return `'${value.replace(/'/g, `"'"'`)}'`;
|
||||
}
|
||||
|
||||
export function escapeDoubleQuotedShellArg(value: string): string {
|
||||
return `"${value.replace(/[\\"$`]/g, "\\$&")}"`;
|
||||
}
|
||||
|
||||
export function shellCommand(command: string): string {
|
||||
return `sh -c ${escapeShellArg(command)}`;
|
||||
}
|
||||
|
||||
export function privilegedShellCommand(
|
||||
server: Server,
|
||||
command: string,
|
||||
): string {
|
||||
return server.username === "root"
|
||||
? shellCommand(command)
|
||||
: `sudo ${shellCommand(command)}`;
|
||||
}
|
||||
|
||||
export async function execPrivileged(
|
||||
server: Server,
|
||||
script: string,
|
||||
): Promise<string> {
|
||||
const result = await exec(server, privilegedShellCommand(server, script));
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || result.stdout || "Remote command failed");
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export async function execPrivilegedLoose(
|
||||
server: Server,
|
||||
script: string,
|
||||
): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
||||
const result = await exec(server, privilegedShellCommand(server, script));
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
code: result.code ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import yaml from "js-yaml";
|
||||
import {
|
||||
readPrivilegedFile,
|
||||
removePrivilegedFile,
|
||||
writePrivilegedFile,
|
||||
} from "./filesystem";
|
||||
import { sanitizeDomainFileName } from "./names";
|
||||
import { execPrivileged } from "./shell";
|
||||
import { DomainProvisioningResult } from "./types";
|
||||
|
||||
interface TraefikStaticConfigLocation {
|
||||
path: string;
|
||||
format: "yaml" | "toml";
|
||||
content: string;
|
||||
}
|
||||
|
||||
async function detectTraefikStaticConfig(
|
||||
server: Server,
|
||||
): Promise<TraefikStaticConfigLocation | null> {
|
||||
const candidates: Array<{ path: string; format: "yaml" | "toml" }> = [
|
||||
{ path: "/etc/traefik/traefik.yml", format: "yaml" },
|
||||
{ path: "/etc/traefik/traefik.yaml", format: "yaml" },
|
||||
{ path: "/etc/traefik/traefik.toml", format: "toml" },
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const content = await readPrivilegedFile(server, candidate.path);
|
||||
if (content !== null) {
|
||||
return { ...candidate, content };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureTraefikFileProvider(server: Server): Promise<string> {
|
||||
const dynamicDirectory = "/etc/traefik/dynamic/portainer";
|
||||
const staticConfig = await detectTraefikStaticConfig(server);
|
||||
if (!staticConfig) {
|
||||
throw new Error(
|
||||
"Traefik static config file was not found. Configure a file provider first so Portainer can write dynamic domain routes.",
|
||||
);
|
||||
}
|
||||
|
||||
if (staticConfig.format === "yaml") {
|
||||
const parsed =
|
||||
(yaml.load(staticConfig.content) as Record<string, unknown> | null) ?? {};
|
||||
const providers =
|
||||
typeof parsed.providers === "object" && parsed.providers !== null
|
||||
? (parsed.providers as Record<string, unknown>)
|
||||
: {};
|
||||
const fileProvider =
|
||||
typeof providers.file === "object" && providers.file !== null
|
||||
? (providers.file as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
if (
|
||||
`${fileProvider.directory ?? ""}`.trim() !== dynamicDirectory ||
|
||||
fileProvider.watch !== true
|
||||
) {
|
||||
parsed.providers = {
|
||||
...providers,
|
||||
file: {
|
||||
...fileProvider,
|
||||
directory: dynamicDirectory,
|
||||
watch: true,
|
||||
},
|
||||
};
|
||||
|
||||
await writePrivilegedFile(
|
||||
server,
|
||||
staticConfig.path,
|
||||
yaml.dump(parsed, { lineWidth: 120 }),
|
||||
);
|
||||
}
|
||||
|
||||
return dynamicDirectory;
|
||||
}
|
||||
|
||||
if (!/\[providers\.file\]/.test(staticConfig.content)) {
|
||||
const nextContent = `${staticConfig.content.trimEnd()}\n\n[providers.file]\n directory = "${dynamicDirectory}"\n watch = true\n`;
|
||||
await writePrivilegedFile(server, staticConfig.path, nextContent);
|
||||
}
|
||||
|
||||
return dynamicDirectory;
|
||||
}
|
||||
|
||||
function buildTraefikDynamicConfig(
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
): string {
|
||||
const key = sanitizeDomainFileName(domainName).replace(/\./g, "-");
|
||||
const parsed = new URL(upstream);
|
||||
|
||||
return yaml.dump(
|
||||
{
|
||||
http: {
|
||||
routers: {
|
||||
[`portainer-${key}`]: {
|
||||
rule: `Host(\`${domainName}\`)`,
|
||||
service: `portainer-${key}`,
|
||||
},
|
||||
},
|
||||
services: {
|
||||
[`portainer-${key}`]: {
|
||||
loadBalancer: {
|
||||
servers: [{ url: `${parsed.protocol}//${parsed.host}` }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ lineWidth: 120 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function reloadTraefik(server: Server): Promise<void> {
|
||||
const script = [
|
||||
"if command -v systemctl >/dev/null 2>&1 && systemctl status traefik >/dev/null 2>&1; then",
|
||||
" systemctl restart traefik",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"if command -v docker >/dev/null 2>&1; then",
|
||||
' TRAEFIK_ID=$(docker ps --format "{{.ID}}|{{.Names}}|{{.Image}}" | awk -F"|" \'BEGIN{IGNORECASE=1} /traefik/ {print $1; exit}\')',
|
||||
' if [ -n "$TRAEFIK_ID" ]; then',
|
||||
' docker restart "$TRAEFIK_ID" >/dev/null',
|
||||
" exit 0",
|
||||
" fi",
|
||||
"fi",
|
||||
"exit 0",
|
||||
].join("; ");
|
||||
|
||||
await execPrivileged(server, script);
|
||||
}
|
||||
|
||||
export async function provisionTraefikConfig(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
upstream: string,
|
||||
): Promise<DomainProvisioningResult> {
|
||||
const dynamicDirectory = await ensureTraefikFileProvider(server);
|
||||
const configPath = `${dynamicDirectory}/${sanitizeDomainFileName(domainName)}.yml`;
|
||||
|
||||
await writePrivilegedFile(
|
||||
server,
|
||||
configPath,
|
||||
buildTraefikDynamicConfig(domainName, upstream),
|
||||
);
|
||||
await reloadTraefik(server);
|
||||
|
||||
return {
|
||||
upstream,
|
||||
configPath,
|
||||
reloadTarget: "traefik",
|
||||
};
|
||||
}
|
||||
|
||||
export async function removeManagedTraefikProxyConfig(options: {
|
||||
server: Server;
|
||||
domainName: string;
|
||||
}): Promise<void> {
|
||||
await removePrivilegedFile(
|
||||
options.server,
|
||||
`/etc/traefik/dynamic/portainer/${sanitizeDomainFileName(options.domainName)}.yml`,
|
||||
);
|
||||
await reloadTraefik(options.server);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Container, ProxyType, Server } from "@prisma/client";
|
||||
|
||||
export interface DomainProvisioningResult {
|
||||
upstream: string;
|
||||
configPath: string;
|
||||
reloadTarget: string;
|
||||
}
|
||||
|
||||
export class DomainProvisioningError extends Error {}
|
||||
|
||||
export interface ContainerPortBinding {
|
||||
containerPort: number;
|
||||
protocol: string;
|
||||
hostPort: number | null;
|
||||
}
|
||||
|
||||
export interface ContainerUpstreamTarget {
|
||||
upstream: string;
|
||||
selectedPort: number;
|
||||
}
|
||||
|
||||
export interface ProvisionDomainProxyOptions {
|
||||
server: Server;
|
||||
domainName: string;
|
||||
domainNames?: string[];
|
||||
nginxServerEntries?: Array<{
|
||||
domainName: string;
|
||||
upstream: string;
|
||||
}>;
|
||||
configMode?: "SHARED" | "ISOLATED";
|
||||
primaryDomainName?: string | null;
|
||||
proxy: ProxyType;
|
||||
sslEnabled: boolean;
|
||||
container: Pick<Container, "id" | "name" | "dockerId" | "serverId">;
|
||||
targetPort: number;
|
||||
}
|
||||
|
||||
export interface RemoveManagedDomainProxyOptions {
|
||||
server: Server;
|
||||
domainName: string;
|
||||
domainNames?: string[];
|
||||
configMode?: "SHARED" | "ISOLATED";
|
||||
proxy: ProxyType;
|
||||
containerName?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import dns from "node:dns/promises";
|
||||
import net from "node:net";
|
||||
import { HeadBucketCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
|
||||
export interface CloudflareVerificationInput {
|
||||
zoneId: string;
|
||||
apiToken: string;
|
||||
}
|
||||
|
||||
export interface AwsVerificationInput {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
export interface TokenVerificationInput {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface GitProviderVerificationInput {
|
||||
provider: "github" | "gitlab" | "bitbucket" | "gitea";
|
||||
providerUrl?: string;
|
||||
appUrl?: string;
|
||||
installationUrl?: string;
|
||||
}
|
||||
|
||||
function toTrimmedUrl(value?: string) {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function isPrivateIpv4Address(hostname: string) {
|
||||
const octets = hostname.split(".").map((part) => Number.parseInt(part, 10));
|
||||
if (octets.length !== 4 || octets.some((part) => Number.isNaN(part))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [first, second] = octets;
|
||||
return (
|
||||
first === 10 ||
|
||||
first === 127 ||
|
||||
first === 0 ||
|
||||
(first === 169 && second === 254) ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && second === 168)
|
||||
);
|
||||
}
|
||||
|
||||
function isPrivateIpAddress(hostname: string) {
|
||||
const ipVersion = net.isIP(hostname);
|
||||
if (ipVersion === 4) {
|
||||
return isPrivateIpv4Address(hostname);
|
||||
}
|
||||
|
||||
if (ipVersion === 6) {
|
||||
const normalized = hostname.toLowerCase();
|
||||
return (
|
||||
normalized === "::1" ||
|
||||
normalized.startsWith("fc") ||
|
||||
normalized.startsWith("fd") ||
|
||||
normalized.startsWith("fe80:") ||
|
||||
normalized === "::"
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLocalHostname(hostname: string) {
|
||||
const normalized = hostname.trim().toLowerCase();
|
||||
return (
|
||||
normalized === "localhost" ||
|
||||
normalized.endsWith(".localhost") ||
|
||||
normalized.endsWith(".local") ||
|
||||
normalized.endsWith(".internal")
|
||||
);
|
||||
}
|
||||
|
||||
async function assertSafeResolvedHostname(hostname: string) {
|
||||
const resolved = await dns.lookup(hostname, { all: true, verbatim: true });
|
||||
if (resolved.some((record) => isPrivateIpAddress(record.address))) {
|
||||
throw new Error(
|
||||
"Private or internal destinations are not allowed for integration verification",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertSafeOutboundUrl(raw: string) {
|
||||
const trimmed = toTrimmedUrl(raw);
|
||||
if (!trimmed) {
|
||||
throw new Error("URL is required");
|
||||
}
|
||||
|
||||
const url = new URL(trimmed);
|
||||
if (!["http:", "https:"].includes(url.protocol)) {
|
||||
throw new Error("Only HTTP and HTTPS URLs are supported");
|
||||
}
|
||||
|
||||
if (process.env.ALLOW_PRIVATE_INTEGRATION_URLS === "true") {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (isLocalHostname(url.hostname) || isPrivateIpAddress(url.hostname)) {
|
||||
throw new Error(
|
||||
"Private or internal destinations are not allowed for integration verification",
|
||||
);
|
||||
}
|
||||
|
||||
await assertSafeResolvedHostname(url.hostname);
|
||||
return url;
|
||||
}
|
||||
|
||||
async function probePublicUrl(url: string) {
|
||||
const safeUrl = await assertSafeOutboundUrl(url);
|
||||
const response = await fetch(safeUrl, {
|
||||
method: "GET",
|
||||
redirect: "follow",
|
||||
headers: {
|
||||
"User-Agent": "Doktainer",
|
||||
Accept: "text/html,application/json;q=0.9,*/*;q=0.8",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`URL check failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyGitProviderConfiguration(
|
||||
input: GitProviderVerificationInput,
|
||||
) {
|
||||
const normalizedProviderUrl = toTrimmedUrl(input.providerUrl);
|
||||
const normalizedAppUrl = toTrimmedUrl(input.appUrl);
|
||||
const normalizedInstallationUrl = toTrimmedUrl(input.installationUrl);
|
||||
|
||||
const baseUrl =
|
||||
normalizedProviderUrl ||
|
||||
(input.provider === "github"
|
||||
? "https://github.com"
|
||||
: input.provider === "gitlab"
|
||||
? "https://gitlab.com"
|
||||
: input.provider === "bitbucket"
|
||||
? "https://bitbucket.org"
|
||||
: "");
|
||||
|
||||
const checks: Array<{ label: string; url: string }> = [];
|
||||
|
||||
if (baseUrl) {
|
||||
checks.push({ label: "Provider URL", url: baseUrl });
|
||||
}
|
||||
|
||||
if (normalizedAppUrl) {
|
||||
checks.push({ label: "App URL", url: normalizedAppUrl });
|
||||
}
|
||||
|
||||
if (normalizedInstallationUrl) {
|
||||
checks.push({ label: "Installation URL", url: normalizedInstallationUrl });
|
||||
}
|
||||
|
||||
if (checks.length === 0) {
|
||||
throw new Error(
|
||||
"At least one provider URL, app URL, or installation URL is required for verification",
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
checks.map(async (check) => ({
|
||||
label: check.label,
|
||||
...(await probePublicUrl(check.url)),
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
message: `${input.provider.toUpperCase()} configuration looks reachable`,
|
||||
detail: {
|
||||
provider: input.provider,
|
||||
checks: results,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyCloudflareConnection(
|
||||
input: CloudflareVerificationInput,
|
||||
) {
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/zones/${encodeURIComponent(input.zoneId)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${input.apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
success?: boolean;
|
||||
result?: { id: string; name: string; status?: string };
|
||||
errors?: Array<{ message?: string }>;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.success || !payload.result) {
|
||||
throw new Error(
|
||||
payload.errors?.[0]?.message || "Cloudflare verification failed",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Connected to Cloudflare zone ${payload.result.name}`,
|
||||
detail: payload.result,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyAwsS3Connection(input: AwsVerificationInput) {
|
||||
if (input.endpoint) {
|
||||
await assertSafeOutboundUrl(input.endpoint);
|
||||
}
|
||||
|
||||
const client = new S3Client({
|
||||
region: input.region,
|
||||
endpoint: input.endpoint || undefined,
|
||||
forcePathStyle: Boolean(input.endpoint),
|
||||
credentials: {
|
||||
accessKeyId: input.accessKeyId,
|
||||
secretAccessKey: input.secretAccessKey,
|
||||
},
|
||||
});
|
||||
|
||||
await client.send(new HeadBucketCommand({ Bucket: input.bucket }));
|
||||
|
||||
return {
|
||||
message: `Connected to S3 bucket ${input.bucket}`,
|
||||
detail: {
|
||||
bucket: input.bucket,
|
||||
region: input.region,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyGithubConnection(input: TokenVerificationInput) {
|
||||
const response = await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${input.token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "VPS-Panel",
|
||||
},
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
login?: string;
|
||||
name?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.login) {
|
||||
throw new Error(payload.message || "GitHub verification failed");
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Connected to GitHub account ${payload.login}`,
|
||||
detail: {
|
||||
login: payload.login,
|
||||
name: payload.name ?? payload.login,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyGitlabConnection(input: TokenVerificationInput) {
|
||||
const response = await fetch("https://gitlab.com/api/v4/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${input.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
username?: string;
|
||||
name?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.username) {
|
||||
throw new Error(
|
||||
payload.message || payload.error || "GitLab verification failed",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Connected to GitLab account ${payload.username}`,
|
||||
detail: {
|
||||
username: payload.username,
|
||||
name: payload.name ?? payload.username,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import prisma from "../lib/prisma";
|
||||
import { decrypt } from "../lib/crypto";
|
||||
import { assertSafeOutboundUrl } from "./integration-verification.service";
|
||||
|
||||
export interface SmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
username?: string;
|
||||
password?: string;
|
||||
fromEmail: string;
|
||||
fromName?: string;
|
||||
}
|
||||
|
||||
type RuntimeNotificationProvider = {
|
||||
id: string;
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
type: string;
|
||||
name: string;
|
||||
actions: string[];
|
||||
channel: string | null;
|
||||
webhookUrlEnc: string | null;
|
||||
smtpHost: string | null;
|
||||
smtpPort: number;
|
||||
smtpSecure: boolean;
|
||||
smtpUsername: string | null;
|
||||
smtpPasswordEnc: string | null;
|
||||
smtpFromEmail: string | null;
|
||||
smtpFromName: string | null;
|
||||
telegramChatId: string | null;
|
||||
telegramBotTokenEnc: string | null;
|
||||
serverUrl: string | null;
|
||||
topic: string | null;
|
||||
userKey: string | null;
|
||||
apiKeyEnc: string | null;
|
||||
};
|
||||
|
||||
export interface RuntimeNotificationEvent {
|
||||
organizationId: string;
|
||||
action: string;
|
||||
title: string;
|
||||
message: string;
|
||||
panelName?: string;
|
||||
serverId?: string;
|
||||
resourceType?: string;
|
||||
resourceId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const DEFAULT_PANEL_NAME = process.env.PANEL_NAME || "DOKTAINER";
|
||||
const RUNTIME_ACTION_LABELS: Record<string, string> = {
|
||||
server_down: "Server Down",
|
||||
high_cpu_over_80: "High CPU (>80%)",
|
||||
high_ram_over_90: "High RAM (>90%)",
|
||||
server_threshold: "Server Threshold",
|
||||
container_crash: "Container Crash",
|
||||
ssl_expiring: "SSL Expiring",
|
||||
security_breach: "Security Breach",
|
||||
app_deploy: "App Deploy",
|
||||
app_build_error: "App Build Error",
|
||||
docker_cleanup: "Docker Cleanup",
|
||||
database_backup: "Database Backup",
|
||||
doktainer_backup: "Doktainer Backup",
|
||||
volume_backup: "Volume Backup",
|
||||
};
|
||||
const RUNTIME_ACTION_ALIASES: Record<string, string> = {
|
||||
backup_database: "database_backup",
|
||||
backup_doktainer: "doktainer_backup",
|
||||
};
|
||||
const runtimeNotificationCooldowns: Record<string, number> = {
|
||||
server_down: 15 * 60 * 1000,
|
||||
high_cpu_over_80: 30 * 60 * 1000,
|
||||
high_ram_over_90: 30 * 60 * 1000,
|
||||
server_threshold: 30 * 60 * 1000,
|
||||
container_crash: 15 * 60 * 1000,
|
||||
ssl_expiring: 24 * 60 * 60 * 1000,
|
||||
security_breach: 10 * 60 * 1000,
|
||||
app_deploy: 5 * 60 * 1000,
|
||||
app_build_error: 5 * 60 * 1000,
|
||||
docker_cleanup: 5 * 60 * 1000,
|
||||
database_backup: 5 * 60 * 1000,
|
||||
volume_backup: 5 * 60 * 1000,
|
||||
doktainer_backup: 5 * 60 * 1000,
|
||||
};
|
||||
const runtimeNotificationRecentDeliveries = new Map<string, number>();
|
||||
|
||||
function normalizeRuntimeAction(action: string) {
|
||||
const normalizedAction = action
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
|
||||
return RUNTIME_ACTION_ALIASES[normalizedAction] ?? normalizedAction;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """);
|
||||
}
|
||||
|
||||
function getRuntimeActionLabel(action: string) {
|
||||
const normalizedAction = normalizeRuntimeAction(action);
|
||||
return (
|
||||
RUNTIME_ACTION_LABELS[normalizedAction] ||
|
||||
normalizedAction
|
||||
.split("_")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
function formatNotificationTimestamp() {
|
||||
return new Date().toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
}
|
||||
|
||||
function toNotificationStatus(
|
||||
action: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
) {
|
||||
const rawStatus = metadata?.status;
|
||||
|
||||
if (typeof rawStatus === "string" && rawStatus.trim()) {
|
||||
return rawStatus.trim().toUpperCase();
|
||||
}
|
||||
|
||||
if (action === "app_build_error" || action === "security_breach") {
|
||||
return "ALERT";
|
||||
}
|
||||
|
||||
if (action === "server_down") {
|
||||
return "OFFLINE";
|
||||
}
|
||||
|
||||
return "INFO";
|
||||
}
|
||||
|
||||
function getNotificationIcon(status: string) {
|
||||
if (["FAILED", "ERROR", "ALERT", "OFFLINE"].includes(status)) {
|
||||
return "🚨";
|
||||
}
|
||||
|
||||
if (["RUNNING", "STARTED", "PROCESSING", "PENDING"].includes(status)) {
|
||||
return "⏳";
|
||||
}
|
||||
|
||||
if (["SUCCESS", "COMPLETED", "ONLINE"].includes(status)) {
|
||||
return "✅";
|
||||
}
|
||||
|
||||
return "🔔";
|
||||
}
|
||||
|
||||
function getDiscordColor(status: string) {
|
||||
if (["FAILED", "ERROR", "ALERT", "OFFLINE"].includes(status)) {
|
||||
return 0xdc2626;
|
||||
}
|
||||
|
||||
if (["RUNNING", "STARTED", "PROCESSING", "PENDING"].includes(status)) {
|
||||
return 0xf59e0b;
|
||||
}
|
||||
|
||||
if (["SUCCESS", "COMPLETED", "ONLINE"].includes(status)) {
|
||||
return 0x16a34a;
|
||||
}
|
||||
|
||||
return 0x2563eb;
|
||||
}
|
||||
|
||||
function formatMetadataValue(value: unknown): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value) : null;
|
||||
}
|
||||
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value.trim() || null;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const items = value
|
||||
.map((entry) => formatMetadataValue(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
return items.length ? items.join(", ") : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type NotificationFact = {
|
||||
label: string;
|
||||
value: string;
|
||||
inline?: boolean;
|
||||
};
|
||||
|
||||
function buildNotificationFacts(input: {
|
||||
panelName: string;
|
||||
action: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
status: string;
|
||||
}) {
|
||||
const facts: NotificationFact[] = [
|
||||
{ label: "Panel", value: input.panelName, inline: true },
|
||||
{
|
||||
label: "Event",
|
||||
value: getRuntimeActionLabel(input.action),
|
||||
inline: true,
|
||||
},
|
||||
{ label: "Status", value: input.status, inline: true },
|
||||
];
|
||||
const metadata = input.metadata ?? {};
|
||||
const pushFact = (label: string, value: unknown, inline = true) => {
|
||||
const formattedValue = formatMetadataValue(value);
|
||||
if (formattedValue) {
|
||||
facts.push({ label, value: formattedValue, inline });
|
||||
}
|
||||
};
|
||||
|
||||
pushFact("Server", metadata.serverName);
|
||||
pushFact("IP", metadata.serverIp);
|
||||
pushFact("Backup", metadata.backupName);
|
||||
pushFact("Type", metadata.backupType);
|
||||
pushFact("Target", metadata.target);
|
||||
|
||||
if (typeof metadata.sizeMb === "number") {
|
||||
pushFact("Size", `${metadata.sizeMb.toFixed(2)} MB`);
|
||||
}
|
||||
|
||||
pushFact("Container", metadata.dbContainer);
|
||||
pushFact("Path", metadata.filePath, false);
|
||||
pushFact("Error", metadata.error, false);
|
||||
|
||||
if (Array.isArray(metadata.breaches)) {
|
||||
const breachSummary = metadata.breaches
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metric = formatMetadataValue(
|
||||
(entry as Record<string, unknown>).metric,
|
||||
);
|
||||
const value = formatMetadataValue(
|
||||
(entry as Record<string, unknown>).value,
|
||||
);
|
||||
const threshold = formatMetadataValue(
|
||||
(entry as Record<string, unknown>).threshold,
|
||||
);
|
||||
|
||||
if (!metric || !value || !threshold) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${metric.toUpperCase()} ${value}% / ${threshold}%`;
|
||||
})
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join("; ");
|
||||
|
||||
if (breachSummary) {
|
||||
facts.push({ label: "Threshold", value: breachSummary, inline: false });
|
||||
}
|
||||
}
|
||||
|
||||
return facts.slice(0, 12);
|
||||
}
|
||||
|
||||
function buildFormattedNotification(input: {
|
||||
action: string;
|
||||
title: string;
|
||||
message: string;
|
||||
panelName: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
const action = normalizeRuntimeAction(input.action);
|
||||
const status = toNotificationStatus(action, input.metadata);
|
||||
const timestamp = formatNotificationTimestamp();
|
||||
const facts = buildNotificationFacts({
|
||||
panelName: input.panelName,
|
||||
action,
|
||||
metadata: input.metadata,
|
||||
status,
|
||||
});
|
||||
const icon = getNotificationIcon(status);
|
||||
const text = [
|
||||
`${icon} ${input.title}`,
|
||||
"",
|
||||
input.message,
|
||||
facts.length
|
||||
? `Detail:\n${facts.map((fact) => `- ${fact.label}: ${fact.value}`).join("\n")}`
|
||||
: "",
|
||||
`${input.panelName} • ${timestamp}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const html = [
|
||||
`<p><strong>${escapeHtml(`${icon} ${input.title}`)}</strong></p>`,
|
||||
`<p>${escapeHtml(input.message)}</p>`,
|
||||
facts.length
|
||||
? `<ul>${facts
|
||||
.map(
|
||||
(fact) =>
|
||||
`<li><strong>${escapeHtml(fact.label)}:</strong> ${escapeHtml(fact.value)}</li>`,
|
||||
)
|
||||
.join("")}</ul>`
|
||||
: "",
|
||||
`<p><em>${escapeHtml(`${input.panelName} • ${timestamp}`)}</em></p>`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
const telegramHtml = [
|
||||
`<b>${escapeHtml(`${icon} ${input.title}`)}</b>`,
|
||||
escapeHtml(input.message),
|
||||
facts.length
|
||||
? `<b>Detail</b>\n${facts
|
||||
.map(
|
||||
(fact) =>
|
||||
`• <b>${escapeHtml(fact.label)}:</b> ${escapeHtml(fact.value)}`,
|
||||
)
|
||||
.join("\n")}`
|
||||
: "",
|
||||
`<i>${escapeHtml(`${input.panelName} • ${timestamp}`)}</i>`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const discordEmbeds = [
|
||||
{
|
||||
title: `${icon} ${input.title}`,
|
||||
description: input.message,
|
||||
color: getDiscordColor(status),
|
||||
fields: facts.map((fact) => ({
|
||||
name: fact.label,
|
||||
value: fact.value,
|
||||
inline: false,
|
||||
})),
|
||||
footer: {
|
||||
text: `${input.panelName} • ${getRuntimeActionLabel(action)}`,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
status,
|
||||
subject: `[${input.panelName}] ${input.title}`,
|
||||
text,
|
||||
html,
|
||||
telegramHtml,
|
||||
discordEmbeds,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeNotificationKey(event: RuntimeNotificationEvent) {
|
||||
const statusKey =
|
||||
typeof event.metadata?.status === "string" && event.metadata.status.trim()
|
||||
? event.metadata.status.trim().toUpperCase()
|
||||
: "INFO";
|
||||
|
||||
return [
|
||||
event.organizationId,
|
||||
normalizeRuntimeAction(event.action),
|
||||
statusKey,
|
||||
event.resourceType ?? "global",
|
||||
event.resourceId ?? event.serverId ?? event.title,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function shouldSendRuntimeNotification(event: RuntimeNotificationEvent) {
|
||||
const action = normalizeRuntimeAction(event.action);
|
||||
const cooldownMs = runtimeNotificationCooldowns[action] ?? 10 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const key = buildRuntimeNotificationKey(event);
|
||||
const lastSentAt = runtimeNotificationRecentDeliveries.get(key);
|
||||
|
||||
if (lastSentAt && now - lastSentAt < cooldownMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
runtimeNotificationRecentDeliveries.set(key, now);
|
||||
|
||||
for (const [entryKey, sentAt] of runtimeNotificationRecentDeliveries) {
|
||||
if (now - sentAt > 24 * 60 * 60 * 1000) {
|
||||
runtimeNotificationRecentDeliveries.delete(entryKey);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendEmailNotification(input: {
|
||||
smtp: SmtpConfig;
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
}) {
|
||||
const transport = nodemailer.createTransport({
|
||||
host: input.smtp.host,
|
||||
port: input.smtp.port,
|
||||
secure: input.smtp.secure,
|
||||
auth: input.smtp.username
|
||||
? {
|
||||
user: input.smtp.username,
|
||||
pass: input.smtp.password,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
await transport.verify();
|
||||
await transport.sendMail({
|
||||
from: input.smtp.fromName
|
||||
? `${input.smtp.fromName} <${input.smtp.fromEmail}>`
|
||||
: input.smtp.fromEmail,
|
||||
to: input.to,
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
html: input.html,
|
||||
});
|
||||
}
|
||||
|
||||
async function sendWebhookNotification(input: {
|
||||
providerType: string;
|
||||
webhookUrl: string;
|
||||
title: string;
|
||||
message: string;
|
||||
text: string;
|
||||
panelName: string;
|
||||
action: string;
|
||||
discordEmbeds?: Array<Record<string, unknown>>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
const providerType = input.providerType.toUpperCase();
|
||||
let body: unknown;
|
||||
|
||||
if (providerType === "DISCORD") {
|
||||
body = {
|
||||
content: `**${input.title}**`,
|
||||
embeds: input.discordEmbeds ?? [],
|
||||
};
|
||||
} else if (providerType === "SLACK" || providerType === "MATTERMOST") {
|
||||
body = {
|
||||
text: input.text,
|
||||
};
|
||||
} else if (providerType === "LARK") {
|
||||
body = {
|
||||
msg_type: "text",
|
||||
content: {
|
||||
text: input.text,
|
||||
},
|
||||
};
|
||||
} else if (providerType === "TEAMS") {
|
||||
body = {
|
||||
title: input.title,
|
||||
text: input.text.replace(/\n/g, "<br/>"),
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
title: input.title,
|
||||
message: input.text,
|
||||
panelName: input.panelName,
|
||||
action: input.action,
|
||||
metadata: input.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(input.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(
|
||||
text || `Webhook notification failed for ${input.providerType}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendGotifyNotification(input: {
|
||||
serverUrl: string;
|
||||
apiKey: string;
|
||||
title: string;
|
||||
message: string;
|
||||
}) {
|
||||
const baseUrl = input.serverUrl.replace(/\/+$/, "");
|
||||
const response = await fetch(`${baseUrl}/message`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Gotify-Key": input.apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
priority: 5,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "Gotify notification failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function sendNtfyNotification(input: {
|
||||
serverUrl: string;
|
||||
topic: string;
|
||||
title: string;
|
||||
message: string;
|
||||
apiKey?: string;
|
||||
}) {
|
||||
const baseUrl = input.serverUrl.replace(/\/+$/, "");
|
||||
const response = await fetch(
|
||||
`${baseUrl}/${encodeURIComponent(input.topic)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...(input.apiKey ? { Authorization: `Bearer ${input.apiKey}` } : {}),
|
||||
Title: input.title,
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
},
|
||||
body: input.message,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "ntfy notification failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPushoverNotification(input: {
|
||||
userKey: string;
|
||||
apiKey: string;
|
||||
title: string;
|
||||
message: string;
|
||||
}) {
|
||||
const response = await fetch("https://api.pushover.net/1/messages.json", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
user: input.userKey,
|
||||
token: input.apiKey,
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "Pushover notification failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function sendResendNotification(input: {
|
||||
apiKey: string;
|
||||
fromEmail: string;
|
||||
fromName?: string | null;
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
}) {
|
||||
const response = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${input.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: input.fromName
|
||||
? `${input.fromName} <${input.fromEmail}>`
|
||||
: input.fromEmail,
|
||||
to: [input.to],
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
html: input.html,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "Resend notification failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function deliverRuntimeNotification(input: {
|
||||
provider: RuntimeNotificationProvider;
|
||||
recipientEmail?: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
telegramHtml: string;
|
||||
panelName: string;
|
||||
action: string;
|
||||
title: string;
|
||||
message: string;
|
||||
discordEmbeds: Array<Record<string, unknown>>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
const { provider } = input;
|
||||
const providerType = provider.type.toUpperCase();
|
||||
|
||||
if (providerType === "EMAIL") {
|
||||
if (
|
||||
!provider.smtpHost ||
|
||||
!provider.smtpFromEmail ||
|
||||
!input.recipientEmail
|
||||
) {
|
||||
throw new Error(
|
||||
`Email provider \"${provider.name}\" is missing SMTP or recipient data`,
|
||||
);
|
||||
}
|
||||
|
||||
await sendEmailNotification({
|
||||
smtp: {
|
||||
host: provider.smtpHost,
|
||||
port: provider.smtpPort,
|
||||
secure: provider.smtpSecure,
|
||||
username: provider.smtpUsername ?? undefined,
|
||||
password: provider.smtpPasswordEnc
|
||||
? decrypt(provider.smtpPasswordEnc)
|
||||
: undefined,
|
||||
fromEmail: provider.smtpFromEmail,
|
||||
fromName: provider.smtpFromName ?? undefined,
|
||||
},
|
||||
to: input.recipientEmail,
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
html: input.html,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerType === "TELEGRAM") {
|
||||
if (!provider.telegramBotTokenEnc || !provider.telegramChatId) {
|
||||
throw new Error(`Telegram provider \"${provider.name}\" is incomplete`);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.telegram.org/bot${decrypt(provider.telegramBotTokenEnc)}/sendMessage`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_id: provider.telegramChatId,
|
||||
text: input.telegramHtml,
|
||||
parse_mode: "HTML",
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.description || "Telegram notification failed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
["DISCORD", "SLACK", "LARK", "TEAMS", "MATTERMOST", "CUSTOM"].includes(
|
||||
providerType,
|
||||
)
|
||||
) {
|
||||
if (!provider.webhookUrlEnc) {
|
||||
throw new Error(
|
||||
`Webhook provider \"${provider.name}\" is missing webhook URL`,
|
||||
);
|
||||
}
|
||||
|
||||
await sendWebhookNotification({
|
||||
providerType,
|
||||
webhookUrl: decrypt(provider.webhookUrlEnc),
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
text: input.text,
|
||||
panelName: input.panelName,
|
||||
action: input.action,
|
||||
discordEmbeds: input.discordEmbeds,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerType === "GOTIFY") {
|
||||
if (!provider.serverUrl || !provider.apiKeyEnc) {
|
||||
throw new Error(`Gotify provider \"${provider.name}\" is incomplete`);
|
||||
}
|
||||
|
||||
await sendGotifyNotification({
|
||||
serverUrl: provider.serverUrl,
|
||||
apiKey: decrypt(provider.apiKeyEnc),
|
||||
title: input.title,
|
||||
message: input.text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerType === "NTFY") {
|
||||
if (!provider.serverUrl || !provider.topic) {
|
||||
throw new Error(`ntfy provider \"${provider.name}\" is incomplete`);
|
||||
}
|
||||
|
||||
await sendNtfyNotification({
|
||||
serverUrl: provider.serverUrl,
|
||||
topic: provider.topic,
|
||||
title: input.title,
|
||||
message: input.text,
|
||||
apiKey: provider.apiKeyEnc ? decrypt(provider.apiKeyEnc) : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerType === "PUSHOVER") {
|
||||
if (!provider.userKey || !provider.apiKeyEnc) {
|
||||
throw new Error(`Pushover provider \"${provider.name}\" is incomplete`);
|
||||
}
|
||||
|
||||
await sendPushoverNotification({
|
||||
userKey: provider.userKey,
|
||||
apiKey: decrypt(provider.apiKeyEnc),
|
||||
title: input.title,
|
||||
message: input.text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerType === "RESEND") {
|
||||
if (
|
||||
!provider.apiKeyEnc ||
|
||||
!provider.smtpFromEmail ||
|
||||
!input.recipientEmail
|
||||
) {
|
||||
throw new Error(`Resend provider \"${provider.name}\" is incomplete`);
|
||||
}
|
||||
|
||||
await sendResendNotification({
|
||||
apiKey: decrypt(provider.apiKeyEnc),
|
||||
fromEmail: provider.smtpFromEmail,
|
||||
fromName: provider.smtpFromName,
|
||||
to: input.recipientEmail,
|
||||
subject: input.subject,
|
||||
text: input.text,
|
||||
html: input.html,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Provider type \"${provider.type}\" is not supported for runtime notifications`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function dispatchRuntimeNotification(
|
||||
event: RuntimeNotificationEvent,
|
||||
) {
|
||||
const action = normalizeRuntimeAction(event.action);
|
||||
if (!action || !event.organizationId) {
|
||||
return { sent: 0, failed: 0, skipped: true };
|
||||
}
|
||||
|
||||
if (!shouldSendRuntimeNotification({ ...event, action })) {
|
||||
return { sent: 0, failed: 0, skipped: true };
|
||||
}
|
||||
|
||||
const allProviders = (await prisma.userNotificationProvider.findMany({
|
||||
where: {
|
||||
organizationId: event.organizationId,
|
||||
enabled: true,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
})) as RuntimeNotificationProvider[];
|
||||
|
||||
const providers = allProviders.filter((provider) =>
|
||||
provider.actions.some(
|
||||
(providerAction) => normalizeRuntimeAction(providerAction) === action,
|
||||
),
|
||||
);
|
||||
|
||||
if (providers.length === 0) {
|
||||
return { sent: 0, failed: 0, skipped: true };
|
||||
}
|
||||
|
||||
const userIds = [...new Set(providers.map((provider) => provider.userId))];
|
||||
const users = userIds.length
|
||||
? await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, email: true },
|
||||
})
|
||||
: [];
|
||||
const userEmails = new Map(users.map((user) => [user.id, user.email]));
|
||||
const panelName = event.panelName || DEFAULT_PANEL_NAME;
|
||||
const formatted = buildFormattedNotification({
|
||||
action,
|
||||
title: event.title,
|
||||
message: event.message,
|
||||
panelName,
|
||||
metadata: event.metadata,
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
providers.map((provider) =>
|
||||
deliverRuntimeNotification({
|
||||
provider,
|
||||
recipientEmail: userEmails.get(provider.userId),
|
||||
subject: formatted.subject,
|
||||
text: formatted.text,
|
||||
html: formatted.html,
|
||||
telegramHtml: formatted.telegramHtml,
|
||||
panelName,
|
||||
action,
|
||||
title: event.title,
|
||||
message: event.message,
|
||||
discordEmbeds: formatted.discordEmbeds,
|
||||
metadata: event.metadata,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "fulfilled") {
|
||||
sent += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
failed += 1;
|
||||
console.error(
|
||||
`[Notification] Failed runtime delivery for provider ${providers[index]?.name || providers[index]?.id || "unknown"}:`,
|
||||
result.reason,
|
||||
);
|
||||
});
|
||||
|
||||
return { sent, failed, skipped: false };
|
||||
}
|
||||
|
||||
export async function sendTestEmailNotification(input: {
|
||||
smtp: SmtpConfig;
|
||||
to: string;
|
||||
panelName: string;
|
||||
}) {
|
||||
await sendEmailNotification({
|
||||
smtp: input.smtp,
|
||||
to: input.to,
|
||||
subject: `${input.panelName} test email notification`,
|
||||
text: `This is a test notification from ${input.panelName}. Your email provider is configured correctly.`,
|
||||
html: `<p>This is a test notification from <strong>${input.panelName}</strong>.</p><p>Your email provider is configured correctly.</p>`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendTestTelegramNotification(input: {
|
||||
token: string;
|
||||
chatId: string;
|
||||
panelName: string;
|
||||
}) {
|
||||
const preview = buildFormattedNotification({
|
||||
action: "security_breach",
|
||||
title: "Integrasi Telegram berhasil",
|
||||
message:
|
||||
"Provider Telegram siap menerima runtime notification dari panel Anda.",
|
||||
panelName: input.panelName,
|
||||
metadata: {
|
||||
status: "SUCCESS",
|
||||
serverName: "Preview Environment",
|
||||
serverIp: "127.0.0.1",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.telegram.org/bot${input.token}/sendMessage`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_id: input.chatId,
|
||||
text: preview.telegramHtml,
|
||||
parse_mode: "HTML",
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
if (!response.ok || !payload.ok) {
|
||||
throw new Error(payload.description || "Telegram test notification failed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendTestDiscordNotification(input: {
|
||||
webhookUrl: string;
|
||||
panelName: string;
|
||||
}) {
|
||||
const safeWebhookUrl = await assertSafeOutboundUrl(input.webhookUrl);
|
||||
const preview = buildFormattedNotification({
|
||||
action: "security_breach",
|
||||
title: "Integrasi Discord berhasil",
|
||||
message:
|
||||
"Webhook Discord siap menerima runtime notification dari panel Anda.",
|
||||
panelName: input.panelName,
|
||||
metadata: {
|
||||
status: "SUCCESS",
|
||||
serverName: "Preview Environment",
|
||||
serverIp: "127.0.0.1",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(safeWebhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: "**Preview notification**",
|
||||
embeds: preview.discordEmbeds,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "Discord test notification failed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendTestWebhookNotification(input: {
|
||||
webhookUrl: string;
|
||||
panelName: string;
|
||||
}) {
|
||||
const safeWebhookUrl = await assertSafeOutboundUrl(input.webhookUrl);
|
||||
const preview = buildFormattedNotification({
|
||||
action: "security_breach",
|
||||
title: "Integrasi Webhook berhasil",
|
||||
message:
|
||||
"Custom webhook siap menerima runtime notification dari panel Anda.",
|
||||
panelName: input.panelName,
|
||||
metadata: {
|
||||
status: "SUCCESS",
|
||||
serverName: "Preview Environment",
|
||||
serverIp: "127.0.0.1",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(safeWebhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: "Preview notification",
|
||||
message: preview.text,
|
||||
panelName: input.panelName,
|
||||
action: "security_breach",
|
||||
metadata: {
|
||||
status: preview.status,
|
||||
preview: true,
|
||||
serverName: "Preview Environment",
|
||||
serverIp: "127.0.0.1",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "Webhook test notification failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export type ProcessJobStatus = "queued" | "running" | "success" | "error";
|
||||
|
||||
export interface ProcessJobLogEntry {
|
||||
id: number;
|
||||
at: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ProcessJob {
|
||||
id: string;
|
||||
type: string;
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
status: ProcessJobStatus;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
logs: ProcessJobLogEntry[];
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
nextLogId: number;
|
||||
subscribers: Set<(entry: ProcessJobLogEntry) => void>;
|
||||
}
|
||||
|
||||
const JOB_TTL_MS = 60 * 60 * 1000;
|
||||
const MAX_LOGS_PER_JOB = 1000;
|
||||
const jobs = new Map<string, ProcessJob>();
|
||||
|
||||
function pruneJobs(now = Date.now()) {
|
||||
for (const [jobId, job] of jobs.entries()) {
|
||||
if (
|
||||
job.subscribers.size === 0 &&
|
||||
(job.status === "success" || job.status === "error") &&
|
||||
now - job.updatedAt > JOB_TTL_MS
|
||||
) {
|
||||
jobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createProcessJob(input: {
|
||||
type: string;
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
}) {
|
||||
pruneJobs();
|
||||
|
||||
const now = Date.now();
|
||||
const job: ProcessJob = {
|
||||
id: randomUUID(),
|
||||
type: input.type,
|
||||
userId: input.userId,
|
||||
organizationId: input.organizationId,
|
||||
status: "queued",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
logs: [],
|
||||
nextLogId: 1,
|
||||
subscribers: new Set(),
|
||||
};
|
||||
|
||||
jobs.set(job.id, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
export function getProcessJob(jobId: string) {
|
||||
pruneJobs();
|
||||
return jobs.get(jobId) ?? null;
|
||||
}
|
||||
|
||||
export function appendProcessJobLog(job: ProcessJob, message: string) {
|
||||
const normalized = message.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const lines = normalized.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
const entry: ProcessJobLogEntry = {
|
||||
id: job.nextLogId,
|
||||
at: new Date().toISOString(),
|
||||
message: line,
|
||||
};
|
||||
|
||||
job.nextLogId += 1;
|
||||
job.updatedAt = Date.now();
|
||||
job.logs.push(entry);
|
||||
|
||||
if (job.logs.length > MAX_LOGS_PER_JOB) {
|
||||
job.logs.splice(0, job.logs.length - MAX_LOGS_PER_JOB);
|
||||
}
|
||||
|
||||
for (const subscriber of job.subscribers) {
|
||||
subscriber(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateProcessJob(
|
||||
job: ProcessJob,
|
||||
patch: {
|
||||
status?: ProcessJobStatus;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
},
|
||||
) {
|
||||
job.status = patch.status ?? job.status;
|
||||
job.result = patch.result ?? job.result;
|
||||
job.error = patch.error ?? job.error;
|
||||
job.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
export function subscribeProcessJob(
|
||||
job: ProcessJob,
|
||||
subscriber: (entry: ProcessJobLogEntry) => void,
|
||||
) {
|
||||
job.subscribers.add(subscriber);
|
||||
|
||||
return () => {
|
||||
job.subscribers.delete(subscriber);
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeProcessJob(job: ProcessJob) {
|
||||
return {
|
||||
id: job.id,
|
||||
type: job.type,
|
||||
status: job.status,
|
||||
createdAt: new Date(job.createdAt).toISOString(),
|
||||
updatedAt: new Date(job.updatedAt).toISOString(),
|
||||
logs: job.logs,
|
||||
result: job.result,
|
||||
error: job.error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
type ServerAccessScope = {
|
||||
role: string;
|
||||
allServersAccess: boolean;
|
||||
serverIds: string[];
|
||||
};
|
||||
|
||||
export async function getServerAccessScope(
|
||||
userId?: string | null,
|
||||
): Promise<ServerAccessScope | null> {
|
||||
if (!userId) return null;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
role: true,
|
||||
allServersAccess: true,
|
||||
serverAssignments: { select: { serverId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return {
|
||||
role: user.role,
|
||||
allServersAccess: user.role === "SUPER_ADMIN" || user.allServersAccess,
|
||||
serverIds: user.serverAssignments.map((assignment) => assignment.serverId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAccessibleServerFilter(userId?: string | null) {
|
||||
return getAccessibleServerFilterForOrganization(userId);
|
||||
}
|
||||
|
||||
export async function getAccessibleServerFilterForOrganization(
|
||||
userId?: string | null,
|
||||
organizationId?: string | null,
|
||||
) {
|
||||
const scope = await getServerAccessScope(userId);
|
||||
if (!scope) {
|
||||
return organizationId ? { organizationId } : undefined;
|
||||
}
|
||||
|
||||
if (scope.allServersAccess) {
|
||||
return organizationId ? { organizationId } : undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(organizationId ? { organizationId } : {}),
|
||||
id: {
|
||||
in: scope.serverIds.length > 0 ? scope.serverIds : ["__no_access__"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function userCanAccessServer(
|
||||
userId: string | undefined,
|
||||
serverId: string,
|
||||
organizationId?: string | null,
|
||||
) {
|
||||
const filter = await getAccessibleServerFilterForOrganization(
|
||||
userId,
|
||||
organizationId,
|
||||
);
|
||||
|
||||
const server = await prisma.server.findFirst({
|
||||
where: {
|
||||
id: serverId,
|
||||
...(filter ?? {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return Boolean(server);
|
||||
}
|
||||
|
||||
export async function getAccessibleServer(
|
||||
userId: string | undefined,
|
||||
serverId: string,
|
||||
organizationId?: string | null,
|
||||
) {
|
||||
const filter = await getAccessibleServerFilterForOrganization(
|
||||
userId,
|
||||
organizationId,
|
||||
);
|
||||
|
||||
return prisma.server.findFirst({
|
||||
where: {
|
||||
id: serverId,
|
||||
...(filter ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# SSH Service Modules
|
||||
|
||||
Folder ini adalah implementasi modular untuk domain SSH service.
|
||||
|
||||
Status saat ini:
|
||||
|
||||
- Implementasi publik sudah dipindahkan dari file lama ke modul-modul di folder ini.
|
||||
- `../ssh.service.ts` sekarang berfungsi sebagai backward-compatibility barrel yang me-re-export API publik dari folder ini.
|
||||
- Backup implementasi monolitik lama disimpan di `../backup/ssh.service.ts` sebagai referensi migrasi.
|
||||
|
||||
Tujuan:
|
||||
|
||||
- Memisahkan domain logic agar mudah dibaca, diuji, dan di-maintain.
|
||||
- Menjaga surface API publik tetap kompatibel selama callsite lama belum dimigrasikan.
|
||||
- Menyediakan pola yang jelas untuk refactor lanjutan.
|
||||
|
||||
## Mapping file → domain
|
||||
|
||||
- `connection.ts`: pooling & lifecycle koneksi, test koneksi
|
||||
- `commands.ts`: eksekusi command (strict / streaming)
|
||||
- `metrics.ts`: pengambilan system metrics + helper format
|
||||
- `platform.ts`: deteksi distro/package manager + status Docker runtime
|
||||
- `server-config.ts`: snapshot konfigurasi server, service list, disk mount, reboot/reset/restart
|
||||
- `web-stack.ts`: inspeksi capability web server & manage komponen (install/upgrade/remove)
|
||||
- `docker-engine.ts`: install/uninstall/reinstall engine + prune
|
||||
- `docker-networks.ts`: list/inspect/create/remove network
|
||||
- `docker-containers.ts`: list container, logs/action/inspect/stats/top + file ops + run container
|
||||
- `firewall.ts`: status & rule UFW/firewall
|
||||
- `fail2ban.ts`: status/enable/disable/install/unban Fail2ban
|
||||
- `domains.ts`: discovery domain
|
||||
- `ssl.ts`: list/resolve/issue/renew/delete SSL certificate
|
||||
|
||||
## Struktur layer
|
||||
|
||||
- `index.ts`: public barrel untuk modul SSH publik.
|
||||
- `internal/*`: helper privat lintas-domain, tidak diexport lewat `index.ts`.
|
||||
- domain file (`connection.ts`, `docker-containers.ts`, dst): hanya berisi API bisnis yang memang ingin dipakai dari luar.
|
||||
|
||||
Rule of thumb:
|
||||
|
||||
- Kalau helper hanya dipakai oleh satu domain, simpan lokal di file domain tersebut.
|
||||
- Kalau helper dipakai lintas domain tapi bukan bagian dari API bisnis, simpan di `internal/`.
|
||||
- Hanya export dari `index.ts` untuk API yang memang boleh dipakai callsite luar.
|
||||
- Hindari domain file import dari `index.ts`; import langsung dari file domain/internal yang dibutuhkan untuk mengurangi risiko circular dependency.
|
||||
|
||||
## Pattern yang dipakai
|
||||
|
||||
- `connection.ts` adalah fondasi koneksi.
|
||||
- `commands.ts` berada di atas `connection.ts` sebagai abstraction untuk eksekusi command.
|
||||
- Modul domain lain bergantung pada `commands.ts`, `platform.ts`, atau helper `internal/*` sesuai kebutuhan.
|
||||
- `internal/*` boleh dipakai oleh beberapa domain, tetapi tidak menjadi bagian dari kontrak publik service.
|
||||
|
||||
Pattern ini sengaja menjaga dependency graph tetap satu arah:
|
||||
|
||||
- `connection` -> dipakai oleh `commands`
|
||||
- `commands`/`platform`/`internal` -> dipakai oleh domain modules
|
||||
- `index` -> hanya re-export public modules
|
||||
|
||||
## Checklist maintainability
|
||||
|
||||
Saat menambah modul baru, usahakan mengikuti checklist ini:
|
||||
|
||||
1. Tentukan dulu apakah logic tersebut domain publik atau helper internal.
|
||||
2. Simpan type/interface sedekat mungkin dengan domain yang memakainya.
|
||||
3. Export hanya API yang memang dibutuhkan callsite luar.
|
||||
4. Jangan import balik dari barrel `index.ts` ke dalam modul domain.
|
||||
5. Jika file domain mulai terlalu besar, pecah lagi berdasarkan subdomain perilaku, bukan berdasarkan jenis syntax.
|
||||
|
||||
Kandidat split lanjutan yang wajar bila file terus membesar:
|
||||
|
||||
- `docker-containers.ts`: bisa dipisah menjadi inventory, runtime actions, dan file operations.
|
||||
- `web-stack.ts`: bisa dipisah menjadi detection dan component management.
|
||||
- `ssl.ts`: bisa dipisah menjadi discovery/resolution dan issue/renew/delete flow.
|
||||
|
||||
## Langkah refactor berikutnya (opsional)
|
||||
|
||||
Kalau sudah siap memecah implementasi, step aman biasanya:
|
||||
|
||||
1. Ubah callsite di routes/services lain secara bertahap agar import dari domain file yang relevan atau dari `services/ssh-services`.
|
||||
2. Pertahankan `ssh.service.ts` sebagai compatibility barrel selama migration window masih dibutuhkan.
|
||||
3. Jaga agar helper baru masuk ke `internal/` bila bukan bagian dari kontrak publik.
|
||||
4. Review file domain yang masih besar, lalu split berdasarkan subdomain logic saat ada perubahan besar berikutnya.
|
||||
@@ -0,0 +1,423 @@
|
||||
import type { SSHExecCommandResponse } from "node-ssh";
|
||||
import { Server } from "@prisma/client";
|
||||
import { AsyncLocalStorage } from "async_hooks";
|
||||
|
||||
import {
|
||||
closeConnection,
|
||||
getConnection,
|
||||
withIsolatedConnection,
|
||||
} from "./connection";
|
||||
|
||||
const commandQueues = new Map<string, Promise<unknown>>();
|
||||
const streamCommandQueues = new Map<string, Promise<unknown>>();
|
||||
const commandLogSink = new AsyncLocalStorage<(data: string) => void>();
|
||||
|
||||
type ExecOptions = {
|
||||
timeoutMs?: number;
|
||||
queueTimeoutMs?: number;
|
||||
};
|
||||
|
||||
function isRecoverableSshError(message: string): boolean {
|
||||
return (
|
||||
message.includes("Channel open failure") ||
|
||||
message.includes("Not connected") ||
|
||||
message.includes("No response from server") ||
|
||||
message.includes("Keepalive timeout") ||
|
||||
message.includes("client-timeout") ||
|
||||
message.includes("Timed out while waiting for handshake") ||
|
||||
message.includes("ECONNRESET")
|
||||
);
|
||||
}
|
||||
|
||||
function logCommandTimeout(server: Server, command: string, timeoutMs: number) {
|
||||
const type = /\bdocker\s+exec\b/.test(command)
|
||||
? "ssh-exec-timeout"
|
||||
: "ssh-timeout";
|
||||
console.warn(
|
||||
`[${type}] ${JSON.stringify({
|
||||
type,
|
||||
serverId: server.id,
|
||||
host: server.ip,
|
||||
duration: timeoutMs,
|
||||
command: command.slice(0, 160),
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
|
||||
function logQueueTimeout(server: Server, command: string, timeoutMs: number) {
|
||||
const type = /\bdocker\s+exec\b/.test(command)
|
||||
? "ssh-exec-queue-timeout"
|
||||
: "ssh-queue-timeout";
|
||||
console.warn(
|
||||
`[${type}] ${JSON.stringify({
|
||||
type,
|
||||
serverId: server.id,
|
||||
host: server.ip,
|
||||
queueWait: timeoutMs,
|
||||
command: command.slice(0, 160),
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
|
||||
function enqueueCommand<T>(
|
||||
queue: Map<string, Promise<unknown>>,
|
||||
server: Server,
|
||||
command: string,
|
||||
task: () => Promise<T>,
|
||||
options: { queueTimeoutMs?: number } = {},
|
||||
): Promise<T> {
|
||||
let started = false;
|
||||
let cancelled = false;
|
||||
const previous = queue.get(server.id) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
if (cancelled) {
|
||||
throw new Error("Command cancelled before execution");
|
||||
}
|
||||
|
||||
started = true;
|
||||
return task();
|
||||
});
|
||||
|
||||
const queued = next.finally(() => {
|
||||
if (queue.get(server.id) === queued) {
|
||||
queue.delete(server.id);
|
||||
}
|
||||
});
|
||||
queue.set(server.id, queued);
|
||||
|
||||
const queueTimeoutMs = options.queueTimeoutMs;
|
||||
if (!queueTimeoutMs || queueTimeoutMs <= 0) {
|
||||
return queued;
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (settled || started) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
cancelled = true;
|
||||
logQueueTimeout(server, command, queueTimeoutMs);
|
||||
reject(
|
||||
new Error(
|
||||
`Command queue timed out after ${Math.ceil(queueTimeoutMs / 1000)}s`,
|
||||
),
|
||||
);
|
||||
}, queueTimeoutMs);
|
||||
timeoutId.unref?.();
|
||||
|
||||
void queued
|
||||
.then((value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function enqueueServerCommand<T>(
|
||||
server: Server,
|
||||
command: string,
|
||||
task: () => Promise<T>,
|
||||
options: { queueTimeoutMs?: number } = {},
|
||||
): Promise<T> {
|
||||
return enqueueCommand(commandQueues, server, command, task, options);
|
||||
}
|
||||
|
||||
function enqueueStreamCommand<T>(
|
||||
server: Server,
|
||||
command: string,
|
||||
task: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return enqueueCommand(streamCommandQueues, server, command, task);
|
||||
}
|
||||
|
||||
function withCommandTimeout<T>(
|
||||
execute: () => Promise<T>,
|
||||
timeoutMs: number | undefined,
|
||||
onTimeout: () => void,
|
||||
): Promise<T> {
|
||||
if (!timeoutMs || timeoutMs <= 0) {
|
||||
return execute();
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
onTimeout();
|
||||
reject(
|
||||
new Error(`Command timed out after ${Math.ceil(timeoutMs / 1000)}s`),
|
||||
);
|
||||
}, timeoutMs);
|
||||
|
||||
void execute()
|
||||
.then((value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return stdout/stderr
|
||||
*/
|
||||
export async function exec(
|
||||
server: Server,
|
||||
command: string,
|
||||
options: ExecOptions = {},
|
||||
): Promise<SSHExecCommandResponse> {
|
||||
const logSink = commandLogSink.getStore();
|
||||
if (logSink) {
|
||||
let stdout = "";
|
||||
let code = 0;
|
||||
|
||||
await streamCommand(
|
||||
server,
|
||||
command,
|
||||
(data) => {
|
||||
stdout += data;
|
||||
logSink(data);
|
||||
},
|
||||
(exitCode) => {
|
||||
code = exitCode;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
code,
|
||||
signal: null,
|
||||
stdout,
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
|
||||
return enqueueServerCommand(
|
||||
server,
|
||||
command,
|
||||
async () => {
|
||||
try {
|
||||
const ssh = await getConnection(server);
|
||||
return await withCommandTimeout(
|
||||
() => ssh.execCommand(command),
|
||||
options.timeoutMs,
|
||||
() => {
|
||||
logCommandTimeout(server, command, options.timeoutMs!);
|
||||
closeConnection(server.id);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const shouldRetry = isRecoverableSshError(message);
|
||||
|
||||
if (!shouldRetry) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
closeConnection(server.id);
|
||||
const ssh = await getConnection(server);
|
||||
return withCommandTimeout(
|
||||
() => ssh.execCommand(command),
|
||||
options.timeoutMs,
|
||||
() => {
|
||||
logCommandTimeout(server, command, options.timeoutMs!);
|
||||
closeConnection(server.id);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function execIsolated(
|
||||
server: Server,
|
||||
command: string,
|
||||
options?: { timeoutMs?: number },
|
||||
): Promise<SSHExecCommandResponse> {
|
||||
const runOnce = () =>
|
||||
withIsolatedConnection(server, async (ssh) =>
|
||||
withCommandTimeout(
|
||||
() => ssh.execCommand(command),
|
||||
options?.timeoutMs,
|
||||
() => {
|
||||
logCommandTimeout(server, command, options!.timeoutMs!);
|
||||
try {
|
||||
ssh.dispose();
|
||||
} catch {
|
||||
// Ignore disposal races after timing out an isolated probe.
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
return await runOnce();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!isRecoverableSshError(message)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return runOnce();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute command, throw on non-zero exit
|
||||
*/
|
||||
export async function execStrict(
|
||||
server: Server,
|
||||
command: string,
|
||||
options: ExecOptions = {},
|
||||
): Promise<string> {
|
||||
const logSink = commandLogSink.getStore();
|
||||
if (logSink) {
|
||||
let output = "";
|
||||
let exitCode = 0;
|
||||
|
||||
await streamCommand(
|
||||
server,
|
||||
command,
|
||||
(data) => {
|
||||
output += data;
|
||||
logSink(data);
|
||||
},
|
||||
(code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Command failed: ${output.trim() || command}`);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
const result = await exec(server, command, options);
|
||||
if (result.code !== 0) {
|
||||
const output = [result.stderr, result.stdout]
|
||||
.map((value) => value?.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
throw new Error(`Command failed: ${output || command}`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export function withCommandLogSink<T>(
|
||||
onData: (data: string) => void,
|
||||
execute: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return commandLogSink.run(onData, execute);
|
||||
}
|
||||
|
||||
export async function execStrictIsolated(
|
||||
server: Server,
|
||||
command: string,
|
||||
options?: { timeoutMs?: number },
|
||||
): Promise<string> {
|
||||
const result = await execIsolated(server, command, options);
|
||||
if (result.code !== 0) {
|
||||
const output = [result.stderr, result.stdout]
|
||||
.map((value) => value?.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
throw new Error(`Command failed: ${output || command}`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
// ─── Process streaming (for WebSocket terminal) ───────────────────────────────
|
||||
|
||||
export async function streamCommand(
|
||||
server: Server,
|
||||
command: string,
|
||||
onData: (data: string) => void,
|
||||
onClose: (code: number) => void,
|
||||
): Promise<void> {
|
||||
return enqueueStreamCommand(server, command, async () => {
|
||||
const ssh = await getConnection(server);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssh.connection!.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
stream.off("data", onStdout);
|
||||
stream.stderr?.off("data", onStderr);
|
||||
stream.off("close", onCloseStream);
|
||||
stream.off("end", onEnd);
|
||||
stream.off("error", onError);
|
||||
stream.stderr?.off("error", onError);
|
||||
stream.destroy();
|
||||
};
|
||||
const finish = (code = 0) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
onClose(code);
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const fail = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const onStdout = (d: Buffer) => onData(d.toString());
|
||||
const onStderr = (d: Buffer) => onData(d.toString());
|
||||
const onCloseStream = (code: number) => finish(code);
|
||||
const onEnd = () => finish(0);
|
||||
const onError = (error: Error) => fail(error);
|
||||
|
||||
stream.on("data", onStdout);
|
||||
stream.stderr?.on("data", onStderr);
|
||||
stream.on("close", onCloseStream);
|
||||
stream.on("end", onEnd);
|
||||
stream.on("error", onError);
|
||||
stream.stderr?.on("error", onError);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { NodeSSH } from "node-ssh";
|
||||
import { Server } from "@prisma/client";
|
||||
import { decrypt } from "../../lib/crypto";
|
||||
|
||||
// Pool: serverId → active SSH connection
|
||||
const pool = new Map<string, NodeSSH>();
|
||||
const connectionPromises = new Map<string, Promise<NodeSSH>>();
|
||||
|
||||
function attachConnectionLifecycle(serverId: string, ssh: NodeSSH): void {
|
||||
const connection = ssh.connection;
|
||||
if (!connection) return;
|
||||
|
||||
connection.on("error", () => {
|
||||
closeConnection(serverId);
|
||||
});
|
||||
|
||||
connection.on("close", () => {
|
||||
closeConnection(serverId);
|
||||
});
|
||||
|
||||
connection.on("end", () => {
|
||||
closeConnection(serverId);
|
||||
});
|
||||
}
|
||||
|
||||
type SshConnectOptions = {
|
||||
readyTimeout?: number;
|
||||
};
|
||||
|
||||
function buildConnectionOptions(
|
||||
server: Server,
|
||||
options: SshConnectOptions = {},
|
||||
) {
|
||||
return {
|
||||
host: server.ip,
|
||||
port: server.sshPort,
|
||||
username: server.username,
|
||||
readyTimeout: options.readyTimeout ?? 10000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 2,
|
||||
};
|
||||
}
|
||||
|
||||
async function connectSsh(
|
||||
ssh: NodeSSH,
|
||||
server: Server,
|
||||
options: SshConnectOptions = {},
|
||||
): Promise<void> {
|
||||
const baseOpts = buildConnectionOptions(server, options);
|
||||
|
||||
if (server.authType === "SSH_KEY" && server.sshKeyEnc) {
|
||||
await ssh.connect({
|
||||
...baseOpts,
|
||||
privateKey: decrypt(server.sshKeyEnc),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (server.passwordEnc) {
|
||||
await ssh.connect({
|
||||
...baseOpts,
|
||||
password: decrypt(server.passwordEnc),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("No SSH credentials configured for this server");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create an SSH connection for a server
|
||||
*/
|
||||
export async function getConnection(server: Server): Promise<NodeSSH> {
|
||||
// Return existing connection if alive
|
||||
const existing = pool.get(server.id);
|
||||
if (existing?.isConnected()) return existing;
|
||||
|
||||
const pending = connectionPromises.get(server.id);
|
||||
if (pending) return pending;
|
||||
|
||||
const connectPromise = (async () => {
|
||||
const ssh = new NodeSSH();
|
||||
await connectSsh(ssh, server);
|
||||
|
||||
attachConnectionLifecycle(server.id, ssh);
|
||||
pool.set(server.id, ssh);
|
||||
return ssh;
|
||||
})().finally(() => {
|
||||
connectionPromises.delete(server.id);
|
||||
});
|
||||
|
||||
connectionPromises.set(server.id, connectPromise);
|
||||
return connectPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close and remove a connection from the pool
|
||||
*/
|
||||
export function closeConnection(serverId: string): void {
|
||||
const conn = pool.get(serverId);
|
||||
pool.delete(serverId);
|
||||
connectionPromises.delete(serverId);
|
||||
|
||||
try {
|
||||
conn?.dispose();
|
||||
} catch {
|
||||
// Ignore connection disposal races during keepalive timeout cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
export async function withIsolatedConnection<T>(
|
||||
server: Server,
|
||||
task: (ssh: NodeSSH) => Promise<T>,
|
||||
options: SshConnectOptions = {},
|
||||
): Promise<T> {
|
||||
const ssh = new NodeSSH();
|
||||
|
||||
try {
|
||||
await connectSsh(ssh, server, options);
|
||||
return await task(ssh);
|
||||
} finally {
|
||||
try {
|
||||
ssh.dispose();
|
||||
} catch {
|
||||
// Ignore disposal races for short-lived isolated probes.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test SSH connectivity — returns true/false
|
||||
*/
|
||||
export async function testConnection(server: Server): Promise<boolean> {
|
||||
try {
|
||||
await withIsolatedConnection(server, (ssh) => ssh.execCommand("echo ok"));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function testConnectionDetailed(
|
||||
server: Server,
|
||||
): Promise<{ connected: boolean; error?: string }> {
|
||||
try {
|
||||
const result = await withIsolatedConnection(server, (ssh) =>
|
||||
ssh.execCommand("echo ok"),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
return {
|
||||
connected: false,
|
||||
error: result.stderr || result.stdout || "SSH command failed",
|
||||
};
|
||||
}
|
||||
return { connected: true };
|
||||
} catch (err: unknown) {
|
||||
closeConnection(server.id);
|
||||
return {
|
||||
connected: false,
|
||||
error: err instanceof Error ? err.message : "SSH connection failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,247 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import { execStrict } from "./commands";
|
||||
import type { DockerRuntimeStatus, ServerPlatformInfo } from "./platform";
|
||||
import { detectServerPlatform, getDockerRuntimeStatus } from "./platform";
|
||||
import { execDocker } from "./internal/docker";
|
||||
import { escapeShellArg } from "./internal/shell";
|
||||
import { privilegedCommand } from "./internal/privilege";
|
||||
|
||||
// NOTE: This file is a modularization of ssh.service.ts (domain: docker-engine).
|
||||
|
||||
const DOCKER_PRUNE_TIMEOUT_MS = 60_000;
|
||||
|
||||
function dockerPruneTimeout() {
|
||||
return {
|
||||
timeoutMs: DOCKER_PRUNE_TIMEOUT_MS,
|
||||
queueTimeoutMs: DOCKER_PRUNE_TIMEOUT_MS,
|
||||
};
|
||||
}
|
||||
|
||||
export interface DockerPruneOptions {
|
||||
images?: boolean;
|
||||
containers?: boolean;
|
||||
networks?: boolean;
|
||||
volumes?: boolean;
|
||||
buildCache?: boolean;
|
||||
}
|
||||
|
||||
export async function pruneDockerArtifacts(
|
||||
server: Server,
|
||||
options: DockerPruneOptions = {},
|
||||
): Promise<{
|
||||
output: string;
|
||||
summary: string;
|
||||
details: string[];
|
||||
docker: DockerRuntimeStatus;
|
||||
}> {
|
||||
const docker = await getDockerRuntimeStatus(server);
|
||||
|
||||
if (!docker.installed) {
|
||||
throw new Error("Docker is not installed on this server");
|
||||
}
|
||||
|
||||
const selectedOptions = {
|
||||
images: options.images === true,
|
||||
containers: options.containers === true,
|
||||
networks: options.networks === true,
|
||||
volumes: options.volumes === true,
|
||||
buildCache: options.buildCache === true,
|
||||
};
|
||||
|
||||
if (!Object.values(selectedOptions).some(Boolean)) {
|
||||
throw new Error("Select at least one Docker artifact to prune");
|
||||
}
|
||||
|
||||
const commands = [
|
||||
selectedOptions.containers
|
||||
? {
|
||||
command: "docker container prune -f 2>&1",
|
||||
summary: "Stopped containers pruned",
|
||||
detailLabel: "Stopped containers",
|
||||
}
|
||||
: null,
|
||||
selectedOptions.networks
|
||||
? {
|
||||
command: "docker network prune -f 2>&1",
|
||||
summary: "Unused networks pruned",
|
||||
detailLabel: "Unused networks",
|
||||
}
|
||||
: null,
|
||||
selectedOptions.images
|
||||
? {
|
||||
command: "docker image prune -a -f 2>&1",
|
||||
summary: "Unused images pruned",
|
||||
detailLabel: "Unused images",
|
||||
}
|
||||
: null,
|
||||
selectedOptions.volumes
|
||||
? {
|
||||
command: "docker volume prune -f 2>&1",
|
||||
summary: "Unused volumes pruned",
|
||||
detailLabel: "Unused volumes",
|
||||
}
|
||||
: null,
|
||||
selectedOptions.buildCache
|
||||
? {
|
||||
command: "docker builder prune -f 2>&1",
|
||||
summary: "Build cache pruned",
|
||||
detailLabel: "Build cache",
|
||||
}
|
||||
: null,
|
||||
].filter(
|
||||
(
|
||||
value,
|
||||
): value is {
|
||||
command: string;
|
||||
summary: string;
|
||||
detailLabel: string;
|
||||
} => Boolean(value),
|
||||
);
|
||||
|
||||
const outputs: string[] = [];
|
||||
const details: string[] = [];
|
||||
const reclaimedTotals: string[] = [];
|
||||
|
||||
for (const item of commands) {
|
||||
const result = await execDocker(server, item.command, dockerPruneTimeout());
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
result.stderr || result.stdout || "Failed to prune Docker data",
|
||||
);
|
||||
}
|
||||
|
||||
const output = result.stdout.trim() || `${item.summary}.`;
|
||||
outputs.push(`== ${item.detailLabel} ==\n${output}`);
|
||||
details.push(item.summary);
|
||||
|
||||
const reclaimedMatch = output.match(/Total reclaimed space:\s*(.+)$/im);
|
||||
if (reclaimedMatch?.[1]?.trim()) {
|
||||
reclaimedTotals.push(`${item.detailLabel}: ${reclaimedMatch[1].trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
const output = outputs.join("\n\n").trim() || "Docker cleanup completed";
|
||||
const summary = [
|
||||
"Docker cleanup completed",
|
||||
reclaimedTotals.length > 0 ? reclaimedTotals.join(". ") : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(". ");
|
||||
|
||||
return {
|
||||
output,
|
||||
summary,
|
||||
details:
|
||||
reclaimedTotals.length > 0 ? [...details, ...reclaimedTotals] : details,
|
||||
docker: await getDockerRuntimeStatus(server),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installDockerEngine(
|
||||
server: Server,
|
||||
): Promise<DockerRuntimeStatus> {
|
||||
const platform = await detectServerPlatform(server);
|
||||
|
||||
if (!platform.packageManager) {
|
||||
throw new Error(
|
||||
`Docker install is not supported on ${platform.distro ?? server.name}: no supported package manager found`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!platform.sudoNonInteractive && server.username !== "root") {
|
||||
throw new Error(
|
||||
`Docker install requires non-interactive sudo access on server ${server.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
const installCommands: Record<
|
||||
NonNullable<ServerPlatformInfo["packageManager"]>,
|
||||
string[]
|
||||
> = {
|
||||
"apt-get": [
|
||||
"DEBIAN_FRONTEND=noninteractive apt-get update",
|
||||
"DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io",
|
||||
"systemctl enable --now docker",
|
||||
],
|
||||
dnf: ["dnf install -y docker", "systemctl enable --now docker"],
|
||||
yum: ["yum install -y docker", "systemctl enable --now docker"],
|
||||
zypper: [
|
||||
"zypper --non-interactive install docker",
|
||||
"systemctl enable --now docker",
|
||||
],
|
||||
apk: [
|
||||
"apk add docker",
|
||||
"rc-update add docker default",
|
||||
"service docker start",
|
||||
],
|
||||
};
|
||||
|
||||
for (const command of installCommands[platform.packageManager]) {
|
||||
await execStrict(server, privilegedCommand(server, command));
|
||||
}
|
||||
|
||||
return getDockerRuntimeStatus(server);
|
||||
}
|
||||
|
||||
export async function uninstallDockerEngine(
|
||||
server: Server,
|
||||
): Promise<DockerRuntimeStatus> {
|
||||
const platform = await detectServerPlatform(server);
|
||||
const docker = await getDockerRuntimeStatus(server);
|
||||
|
||||
if (!docker.installed) {
|
||||
return docker;
|
||||
}
|
||||
|
||||
if (!platform.packageManager) {
|
||||
throw new Error(
|
||||
`Docker removal is not supported on ${platform.distro ?? server.name}: no supported package manager found`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!platform.sudoNonInteractive && server.username !== "root") {
|
||||
throw new Error(
|
||||
`Docker removal requires non-interactive sudo access on server ${server.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
const removeCommands: Record<
|
||||
NonNullable<ServerPlatformInfo["packageManager"]>,
|
||||
string[]
|
||||
> = {
|
||||
"apt-get": [
|
||||
"systemctl disable --now docker || true",
|
||||
"DEBIAN_FRONTEND=noninteractive apt-get remove -y docker.io docker docker-engine docker-ce docker-ce-cli containerd runc || true",
|
||||
],
|
||||
dnf: [
|
||||
"systemctl disable --now docker || true",
|
||||
"dnf remove -y docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-engine containerd.io || true",
|
||||
],
|
||||
yum: [
|
||||
"systemctl disable --now docker || true",
|
||||
"yum remove -y docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-engine containerd.io || true",
|
||||
],
|
||||
zypper: [
|
||||
"systemctl disable --now docker || true",
|
||||
"zypper --non-interactive remove docker || true",
|
||||
],
|
||||
apk: ["service docker stop || true", "apk del docker || true"],
|
||||
};
|
||||
|
||||
for (const command of removeCommands[platform.packageManager]) {
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(server, `bash -lc ${escapeShellArg(command)}`),
|
||||
);
|
||||
}
|
||||
|
||||
return getDockerRuntimeStatus(server);
|
||||
}
|
||||
|
||||
export async function reinstallDockerEngine(
|
||||
server: Server,
|
||||
): Promise<DockerRuntimeStatus> {
|
||||
await uninstallDockerEngine(server);
|
||||
return installDockerEngine(server);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import { execDockerStrict } from "./internal/docker";
|
||||
import { escapeShellArg } from "./internal/shell";
|
||||
|
||||
// NOTE: This file is a modularization of ssh.service.ts (domain: docker-networks).
|
||||
|
||||
const DOCKER_NETWORK_LIST_TIMEOUT_MS = 10_000;
|
||||
const DOCKER_NETWORK_INSPECT_TIMEOUT_MS = 10_000;
|
||||
const DOCKER_NETWORK_MUTATION_TIMEOUT_MS = 20_000;
|
||||
|
||||
function dockerNetworkCommandTimeout(timeoutMs: number) {
|
||||
return { timeoutMs, queueTimeoutMs: timeoutMs };
|
||||
}
|
||||
|
||||
export interface DockerNetworkInfo {
|
||||
name: string;
|
||||
driver: string;
|
||||
scope: string;
|
||||
subnet?: string;
|
||||
gateway?: string;
|
||||
containers: number;
|
||||
}
|
||||
|
||||
export interface DockerNetworkContainer {
|
||||
id: string;
|
||||
name: string;
|
||||
endpointId: string | null;
|
||||
macAddress: string | null;
|
||||
ipv4Address: string | null;
|
||||
ipv6Address: string | null;
|
||||
}
|
||||
|
||||
export interface DockerNetworkInspect {
|
||||
name: string;
|
||||
id: string | null;
|
||||
created: string | null;
|
||||
scope: string;
|
||||
driver: string;
|
||||
enableIPv4: boolean | null;
|
||||
enableIPv6: boolean | null;
|
||||
internal: boolean;
|
||||
attachable: boolean;
|
||||
ingress: boolean;
|
||||
labels: Record<string, string>;
|
||||
options: Record<string, string>;
|
||||
subnet: string | null;
|
||||
gateway: string | null;
|
||||
containers: DockerNetworkContainer[];
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function listDockerNetworks(
|
||||
server: Server,
|
||||
): Promise<DockerNetworkInfo[]> {
|
||||
const networkList = await execDockerStrict(
|
||||
server,
|
||||
`docker network ls --format '{{.Name}}|{{.Driver}}|{{.Scope}}'`,
|
||||
dockerNetworkCommandTimeout(DOCKER_NETWORK_LIST_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
if (!networkList.trim()) return [];
|
||||
|
||||
const rawNetworks = networkList
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const [name, driver, scope] = line.split("|");
|
||||
return {
|
||||
name: name?.trim() || "",
|
||||
driver: driver?.trim() || "bridge",
|
||||
scope: scope?.trim() || "local",
|
||||
};
|
||||
})
|
||||
.filter((item) => item.name);
|
||||
|
||||
const inspected = await Promise.all(
|
||||
rawNetworks.map(async (network) => {
|
||||
try {
|
||||
const inspectOutput = await execDockerStrict(
|
||||
server,
|
||||
`docker network inspect ${escapeShellArg(network.name)}`,
|
||||
dockerNetworkCommandTimeout(DOCKER_NETWORK_INSPECT_TIMEOUT_MS),
|
||||
);
|
||||
const parsed = JSON.parse(inspectOutput) as Array<{
|
||||
IPAM?: { Config?: Array<{ Subnet?: string; Gateway?: string }> };
|
||||
Containers?: Record<string, unknown>;
|
||||
}>;
|
||||
const details = parsed[0] ?? {};
|
||||
const ipam = details.IPAM?.Config?.[0];
|
||||
const containers = Object.keys(details.Containers ?? {}).length;
|
||||
|
||||
return {
|
||||
...network,
|
||||
subnet: ipam?.Subnet,
|
||||
gateway: ipam?.Gateway,
|
||||
containers,
|
||||
} satisfies DockerNetworkInfo;
|
||||
} catch {
|
||||
return {
|
||||
...network,
|
||||
containers: 0,
|
||||
} satisfies DockerNetworkInfo;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return inspected.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
export async function dockerNetworkInspect(
|
||||
server: Server,
|
||||
networkName: string,
|
||||
): Promise<DockerNetworkInspect> {
|
||||
const stdout = await execDockerStrict(
|
||||
server,
|
||||
`docker network inspect ${escapeShellArg(networkName)}`,
|
||||
dockerNetworkCommandTimeout(DOCKER_NETWORK_INSPECT_TIMEOUT_MS),
|
||||
);
|
||||
const parsed = JSON.parse(stdout) as Array<{
|
||||
Name?: string;
|
||||
Id?: string;
|
||||
Created?: string;
|
||||
Scope?: string;
|
||||
Driver?: string;
|
||||
EnableIPv4?: boolean;
|
||||
EnableIPv6?: boolean;
|
||||
Internal?: boolean;
|
||||
Attachable?: boolean;
|
||||
Ingress?: boolean;
|
||||
Labels?: Record<string, string> | null;
|
||||
Options?: Record<string, string> | null;
|
||||
IPAM?: { Config?: Array<{ Subnet?: string; Gateway?: string }> };
|
||||
Containers?: Record<
|
||||
string,
|
||||
{
|
||||
Name?: string;
|
||||
EndpointID?: string;
|
||||
MacAddress?: string;
|
||||
IPv4Address?: string;
|
||||
IPv6Address?: string;
|
||||
}
|
||||
>;
|
||||
}>;
|
||||
|
||||
const detail = parsed[0] ?? {};
|
||||
const ipam = detail.IPAM?.Config?.[0];
|
||||
const containers = Object.entries(detail.Containers ?? {}).map(
|
||||
([containerId, container]) => ({
|
||||
id: containerId,
|
||||
name: container?.Name ?? containerId,
|
||||
endpointId: container?.EndpointID ?? null,
|
||||
macAddress: container?.MacAddress ?? null,
|
||||
ipv4Address: container?.IPv4Address ?? null,
|
||||
ipv6Address: container?.IPv6Address ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
name: detail.Name ?? networkName,
|
||||
id: detail.Id ?? null,
|
||||
created: detail.Created ?? null,
|
||||
scope: detail.Scope ?? "local",
|
||||
driver: detail.Driver ?? "bridge",
|
||||
enableIPv4: detail.EnableIPv4 ?? null,
|
||||
enableIPv6: detail.EnableIPv6 ?? null,
|
||||
internal: Boolean(detail.Internal),
|
||||
attachable: Boolean(detail.Attachable),
|
||||
ingress: Boolean(detail.Ingress),
|
||||
labels: detail.Labels ?? {},
|
||||
options: detail.Options ?? {},
|
||||
subnet: ipam?.Subnet ?? null,
|
||||
gateway: ipam?.Gateway ?? null,
|
||||
containers: containers.sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
),
|
||||
raw: (detail as Record<string, unknown>) ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDockerNetwork(
|
||||
server: Server,
|
||||
opts: {
|
||||
name: string;
|
||||
driver?: string;
|
||||
subnet?: string;
|
||||
gateway?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const flags = [
|
||||
opts.driver?.trim() ? `--driver ${escapeShellArg(opts.driver.trim())}` : "",
|
||||
opts.subnet?.trim() ? `--subnet ${escapeShellArg(opts.subnet.trim())}` : "",
|
||||
opts.gateway?.trim()
|
||||
? `--gateway ${escapeShellArg(opts.gateway.trim())}`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
await execDockerStrict(
|
||||
server,
|
||||
`docker network create ${flags} ${escapeShellArg(opts.name.trim())}`,
|
||||
dockerNetworkCommandTimeout(DOCKER_NETWORK_MUTATION_TIMEOUT_MS),
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeDockerNetwork(
|
||||
server: Server,
|
||||
networkName: string,
|
||||
): Promise<void> {
|
||||
await execDockerStrict(
|
||||
server,
|
||||
`docker network rm ${escapeShellArg(networkName.trim())}`,
|
||||
dockerNetworkCommandTimeout(DOCKER_NETWORK_MUTATION_TIMEOUT_MS),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import { exec } from "./commands";
|
||||
import { escapeShellArg } from "./internal/shell";
|
||||
import { privilegedCommand } from "./internal/privilege";
|
||||
|
||||
// NOTE: This file is a modularization of ssh.service.ts (domain: domains).
|
||||
|
||||
const DOMAIN_DISCOVERY_TIMEOUT_MS = 12_000;
|
||||
const DOMAIN_FILE_DISCOVERY_TIMEOUT_MS = 10_000;
|
||||
|
||||
function domainDiscoveryTimeout(timeoutMs: number) {
|
||||
return { timeoutMs, queueTimeoutMs: timeoutMs };
|
||||
}
|
||||
|
||||
export interface DiscoveredDomain {
|
||||
name: string;
|
||||
proxy: "TRAEFIK" | "NGINX" | "CADDY" | "NONE";
|
||||
discoverySource: "NGINX" | "TRAEFIK" | "CADDY" | "CADDY_ADMIN" | "CERTBOT";
|
||||
value: string | null;
|
||||
sslEnabled: boolean;
|
||||
dockerId?: string | null;
|
||||
containerName?: string | null;
|
||||
targetPort?: number | null;
|
||||
sourceConfigPath?: string | null;
|
||||
managedByDoktainer?: boolean;
|
||||
managedConfigMode?: "SHARED" | "ISOLATED" | null;
|
||||
managedPrimaryDomain?: string | null;
|
||||
}
|
||||
|
||||
interface DockerInspectLabels {
|
||||
Id?: string;
|
||||
Name?: string;
|
||||
Config?: {
|
||||
Image?: string;
|
||||
Labels?: Record<string, string> | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface CaddyJsonNode {
|
||||
match?: Array<{
|
||||
host?: string[];
|
||||
}>;
|
||||
handle?: CaddyJsonNode[];
|
||||
routes?: CaddyJsonNode[];
|
||||
subroute?: {
|
||||
routes?: CaddyJsonNode[];
|
||||
};
|
||||
servers?: Record<string, { routes?: CaddyJsonNode[] }>;
|
||||
apps?: {
|
||||
http?: {
|
||||
servers?: Record<string, { routes?: CaddyJsonNode[] }>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDomainName(value: string): string | null {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[.;]+$/g, "");
|
||||
|
||||
if (!normalized) return null;
|
||||
if (normalized === "_" || normalized === "localhost") return null;
|
||||
if (normalized.includes("*") || normalized.startsWith("~")) return null;
|
||||
if (!normalized.includes(".")) return null;
|
||||
if (!/^[a-z0-9.-]+$/.test(normalized)) return null;
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseHostRuleDomains(rule: string): string[] {
|
||||
const domains = new Set<string>();
|
||||
|
||||
for (const match of rule.matchAll(/Host(?:Regexp)?\(([^)]*)\)/gi)) {
|
||||
const args = match[1] ?? "";
|
||||
for (const token of args.split(",")) {
|
||||
const cleaned = token.replace(/["'`{}\s]/g, "").trim();
|
||||
if (!cleaned || cleaned.includes(":")) continue;
|
||||
|
||||
const name = normalizeDomainName(cleaned);
|
||||
if (name) {
|
||||
domains.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(domains);
|
||||
}
|
||||
|
||||
function normalizeContainerName(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
const normalized = `${value ?? ""}`
|
||||
.trim()
|
||||
.replace(/^\/+/, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9.-]/g, "-");
|
||||
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function extractNumericPortCandidate(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const upstreamTemplateMatch = trimmed.match(
|
||||
/\{\{\s*upstreams\s+(\d{1,5})\s*\}\}/i,
|
||||
);
|
||||
if (upstreamTemplateMatch) {
|
||||
const port = Number(upstreamTemplateMatch[1]);
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
|
||||
}
|
||||
|
||||
const portMatch = trimmed.match(/:(\d{1,5})(?:\b|\/|$)/);
|
||||
if (!portMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = Number(portMatch[1]);
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
|
||||
}
|
||||
|
||||
function isLikelyIpv4Host(value: string): boolean {
|
||||
return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value.trim());
|
||||
}
|
||||
|
||||
function isLikelyIpv6Host(value: string): boolean {
|
||||
return value.includes(":");
|
||||
}
|
||||
|
||||
function isLocalhostHost(value: string): boolean {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\[|\]$/g, "");
|
||||
return (
|
||||
normalized === "localhost" ||
|
||||
normalized === "127.0.0.1" ||
|
||||
normalized === "0.0.0.0" ||
|
||||
normalized === "::1"
|
||||
);
|
||||
}
|
||||
|
||||
function extractProxyPassTarget(content: string): {
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
} {
|
||||
const sanitized = content.replace(/^\s*#.*$/gm, "");
|
||||
|
||||
for (const match of sanitized.matchAll(
|
||||
/\bproxy_pass\s+(https?:\/\/([^;\s/]+)(?:\/[^;\s]*)?)\s*;/gi,
|
||||
)) {
|
||||
const authority = `${match[2] ?? ""}`.trim();
|
||||
if (!authority) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedAuthority = authority.replace(/^\[|\]$/g, "");
|
||||
const lastColonIndex = normalizedAuthority.lastIndexOf(":");
|
||||
const host =
|
||||
lastColonIndex > -1
|
||||
? normalizedAuthority.slice(0, lastColonIndex)
|
||||
: normalizedAuthority;
|
||||
|
||||
return {
|
||||
host: host.trim() || null,
|
||||
port: extractNumericPortCandidate(match[1] ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
return { host: null, port: null };
|
||||
}
|
||||
|
||||
function extractTraefikTargetPort(
|
||||
labels: Record<string, string>,
|
||||
): number | null {
|
||||
for (const [key, value] of Object.entries(labels)) {
|
||||
if (
|
||||
/^traefik\.(http|tcp)\.services\.[^.]+\.loadbalancer\.server\.port$/i.test(
|
||||
key,
|
||||
)
|
||||
) {
|
||||
const port = Number(value.trim());
|
||||
if (Number.isInteger(port) && port >= 1 && port <= 65535) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractCaddyTargetPort(labels: Record<string, string>): number | null {
|
||||
for (const [key, value] of Object.entries(labels)) {
|
||||
if (
|
||||
/^caddy(?:_\d+)?\.reverse_proxy$/i.test(key) ||
|
||||
/^caddy\.reverse_proxy(?:_\d+)?$/i.test(key)
|
||||
) {
|
||||
const port = extractNumericPortCandidate(value);
|
||||
if (port) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseMarkedFileChunk(chunk: string): {
|
||||
path: string | null;
|
||||
content: string;
|
||||
} {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed) {
|
||||
return { path: null, content: "" };
|
||||
}
|
||||
|
||||
const lines = trimmed.split(/\r?\n/);
|
||||
const firstLine = lines[0] ?? "";
|
||||
if (firstLine.startsWith("__FILE__:")) {
|
||||
return {
|
||||
path: firstLine.slice("__FILE__:".length).trim() || null,
|
||||
content: lines.slice(1).join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
return { path: null, content: trimmed };
|
||||
}
|
||||
|
||||
function deriveManagedNginxContainerName(
|
||||
filePath: string | null,
|
||||
domainName: string,
|
||||
): string | null {
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedDomain = normalizeDomainName(domainName);
|
||||
if (!normalizedDomain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileName = filePath.replace(/^.*[\\/]/, "").replace(/\.conf$/i, "");
|
||||
const suffix = `--${normalizedDomain.replace(/[^a-z0-9.-]/g, "-")}`;
|
||||
if (!fileName.toLowerCase().endsWith(suffix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fileName.slice(0, -suffix.length) || null;
|
||||
}
|
||||
|
||||
function extractManagedNginxMetadata(content: string): {
|
||||
managedByDoktainer: boolean;
|
||||
managedConfigMode: "SHARED" | "ISOLATED" | null;
|
||||
managedPrimaryDomain: string | null;
|
||||
} {
|
||||
const normalized = content.replace(/\r/g, "");
|
||||
const managedByDoktainer = /#\s*doktainer-managed\s*:\s*true/i.test(
|
||||
normalized,
|
||||
);
|
||||
const managedConfigModeMatch = normalized.match(
|
||||
/#\s*doktainer-config-mode\s*:\s*(SHARED|ISOLATED)/i,
|
||||
);
|
||||
const primaryDomainMatch = normalized.match(
|
||||
/#\s*doktainer-primary-domain\s*:\s*([^\n#]+)/i,
|
||||
);
|
||||
|
||||
return {
|
||||
managedByDoktainer,
|
||||
managedConfigMode:
|
||||
managedConfigModeMatch?.[1]?.toUpperCase() === "SHARED"
|
||||
? "SHARED"
|
||||
: managedConfigModeMatch?.[1]?.toUpperCase() === "ISOLATED"
|
||||
? "ISOLATED"
|
||||
: null,
|
||||
managedPrimaryDomain:
|
||||
normalizeDomainName(primaryDomainMatch?.[1]?.trim() ?? "") ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function addDiscoveredDomain(
|
||||
collection: Map<string, DiscoveredDomain>,
|
||||
domainName: string,
|
||||
proxy: DiscoveredDomain["proxy"],
|
||||
discoverySource: DiscoveredDomain["discoverySource"],
|
||||
value: string | null,
|
||||
sslEnabled = false,
|
||||
metadata?: {
|
||||
dockerId?: string | null;
|
||||
containerName?: string | null;
|
||||
targetPort?: number | null;
|
||||
sourceConfigPath?: string | null;
|
||||
managedByDoktainer?: boolean;
|
||||
managedConfigMode?: "SHARED" | "ISOLATED" | null;
|
||||
managedPrimaryDomain?: string | null;
|
||||
},
|
||||
): void {
|
||||
const normalized = normalizeDomainName(domainName);
|
||||
if (!normalized) return;
|
||||
|
||||
const existing = collection.get(normalized);
|
||||
collection.set(normalized, {
|
||||
name: normalized,
|
||||
proxy:
|
||||
existing?.proxy === "NGINX"
|
||||
? existing.proxy
|
||||
: existing?.proxy === "TRAEFIK" && proxy === "CADDY"
|
||||
? existing.proxy
|
||||
: proxy !== "NONE"
|
||||
? proxy
|
||||
: (existing?.proxy ?? "NONE"),
|
||||
discoverySource:
|
||||
existing?.discoverySource === "NGINX"
|
||||
? existing.discoverySource
|
||||
: existing?.discoverySource === "TRAEFIK" &&
|
||||
discoverySource === "CADDY_ADMIN"
|
||||
? existing.discoverySource
|
||||
: discoverySource,
|
||||
value: existing?.value ?? value,
|
||||
sslEnabled: existing?.sslEnabled || sslEnabled || false,
|
||||
dockerId: existing?.dockerId ?? metadata?.dockerId ?? null,
|
||||
containerName:
|
||||
existing?.containerName ??
|
||||
normalizeContainerName(metadata?.containerName) ??
|
||||
null,
|
||||
targetPort: existing?.targetPort ?? metadata?.targetPort ?? null,
|
||||
sourceConfigPath:
|
||||
existing?.sourceConfigPath ?? metadata?.sourceConfigPath ?? null,
|
||||
managedByDoktainer:
|
||||
existing?.managedByDoktainer ?? metadata?.managedByDoktainer ?? false,
|
||||
managedConfigMode:
|
||||
existing?.managedConfigMode ?? metadata?.managedConfigMode ?? null,
|
||||
managedPrimaryDomain:
|
||||
existing?.managedPrimaryDomain ?? metadata?.managedPrimaryDomain ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function extractCaddyfileDomains(content: string): string[] {
|
||||
const domains = new Set<string>();
|
||||
|
||||
for (const rawLine of content.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
if (line.startsWith("import ")) continue;
|
||||
if (line.startsWith("(") || line === "}") continue;
|
||||
if (!line.includes("{")) continue;
|
||||
|
||||
const addressSection = line.replace(/\{.*$/, "").trim();
|
||||
if (!addressSection) continue;
|
||||
|
||||
for (const token of addressSection.split(",")) {
|
||||
const candidate = token
|
||||
.trim()
|
||||
.replace(/^https?:\/\//i, "")
|
||||
.replace(/:\d+$/, "")
|
||||
.split(/\s+/)[0]
|
||||
?.trim();
|
||||
|
||||
const domainName = normalizeDomainName(candidate ?? "");
|
||||
if (domainName) {
|
||||
domains.add(domainName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(domains);
|
||||
}
|
||||
|
||||
function collectDomainsFromCaddyJsonNode(
|
||||
node: unknown,
|
||||
bucket: Set<string>,
|
||||
): void {
|
||||
if (!node || typeof node !== "object") return;
|
||||
|
||||
const typed = node as CaddyJsonNode;
|
||||
|
||||
for (const matcher of typed.match ?? []) {
|
||||
for (const host of matcher.host ?? []) {
|
||||
const domainName = normalizeDomainName(host);
|
||||
if (domainName) {
|
||||
bucket.add(domainName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const route of typed.routes ?? []) {
|
||||
collectDomainsFromCaddyJsonNode(route, bucket);
|
||||
}
|
||||
|
||||
for (const route of typed.handle ?? []) {
|
||||
collectDomainsFromCaddyJsonNode(route, bucket);
|
||||
}
|
||||
|
||||
for (const route of typed.subroute?.routes ?? []) {
|
||||
collectDomainsFromCaddyJsonNode(route, bucket);
|
||||
}
|
||||
|
||||
for (const server of Object.values(typed.servers ?? {})) {
|
||||
for (const route of server.routes ?? []) {
|
||||
collectDomainsFromCaddyJsonNode(route, bucket);
|
||||
}
|
||||
}
|
||||
|
||||
for (const server of Object.values(typed.apps?.http?.servers ?? {})) {
|
||||
for (const route of server.routes ?? []) {
|
||||
collectDomainsFromCaddyJsonNode(route, bucket);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractCaddyJsonDomains(content: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
const domains = new Set<string>();
|
||||
collectDomainsFromCaddyJsonNode(parsed, domains);
|
||||
return Array.from(domains);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function extractNginxServerNameDomains(content: string): string[] {
|
||||
const domains = new Set<string>();
|
||||
const sanitized = content.replace(/^\s*#.*$/gm, "");
|
||||
|
||||
for (const match of sanitized.matchAll(/\bserver_name\b([\s\S]*?);/gi)) {
|
||||
const value = (match[1] ?? "")
|
||||
.replace(/\\\r?\n/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const token of value.split(/\s+/)) {
|
||||
const normalized = normalizeDomainName(
|
||||
token.replace(/^["']+|["']+$/g, ""),
|
||||
);
|
||||
if (normalized) {
|
||||
domains.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(domains);
|
||||
}
|
||||
|
||||
async function listNginxDomains(server: Server): Promise<DiscoveredDomain[]> {
|
||||
const [configDump, fileDump] = await Promise.all([
|
||||
exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
[
|
||||
"if command -v nginx >/dev/null 2>&1; then",
|
||||
` ${privilegedCommand(server, "nginx -T")} 2>&1 || true`,
|
||||
"fi",
|
||||
].join(" "),
|
||||
)}`,
|
||||
),
|
||||
exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
[
|
||||
"for path in /etc/nginx/nginx.conf /etc/nginx/sites-enabled /etc/nginx/conf.d /usr/local/nginx/conf/nginx.conf /usr/local/nginx/conf/conf.d; do",
|
||||
' if [ -f "$path" ]; then',
|
||||
' printf "__FILE__:%s\\n" "$path";',
|
||||
' cat "$path" 2>/dev/null;',
|
||||
' printf "\\n__END_FILE__\\n";',
|
||||
' elif [ -d "$path" ]; then',
|
||||
' find -L "$path" -maxdepth 3 -type f 2>/dev/null | while IFS= read -r file; do',
|
||||
' printf "__FILE__:%s\\n" "$file";',
|
||||
' cat "$file" 2>/dev/null;',
|
||||
' printf "\\n__END_FILE__\\n";',
|
||||
" done;",
|
||||
" fi",
|
||||
"done",
|
||||
].join(" "),
|
||||
)}`,
|
||||
),
|
||||
]);
|
||||
|
||||
const domains = new Map<string, DiscoveredDomain>();
|
||||
|
||||
for (const content of [
|
||||
configDump.stdout,
|
||||
configDump.stderr,
|
||||
...fileDump.stdout.split("__END_FILE__").map((chunk) => {
|
||||
const parsed = parseMarkedFileChunk(chunk);
|
||||
return parsed.content;
|
||||
}),
|
||||
]) {
|
||||
for (const domainName of extractNginxServerNameDomains(content)) {
|
||||
addDiscoveredDomain(domains, domainName, "NGINX", "NGINX", server.ip);
|
||||
}
|
||||
}
|
||||
|
||||
for (const chunk of fileDump.stdout.split("__END_FILE__")) {
|
||||
const { path, content } = parseMarkedFileChunk(chunk);
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const proxyPassTarget = extractProxyPassTarget(content);
|
||||
const managedMetadata = extractManagedNginxMetadata(content);
|
||||
for (const domainName of extractNginxServerNameDomains(content)) {
|
||||
addDiscoveredDomain(
|
||||
domains,
|
||||
domainName,
|
||||
"NGINX",
|
||||
"NGINX",
|
||||
server.ip,
|
||||
false,
|
||||
{
|
||||
sourceConfigPath: path,
|
||||
managedByDoktainer: managedMetadata.managedByDoktainer,
|
||||
managedConfigMode: managedMetadata.managedConfigMode,
|
||||
managedPrimaryDomain: managedMetadata.managedPrimaryDomain,
|
||||
containerName:
|
||||
deriveManagedNginxContainerName(path, domainName) ??
|
||||
(proxyPassTarget.host &&
|
||||
!isLocalhostHost(proxyPassTarget.host) &&
|
||||
!isLikelyIpv4Host(proxyPassTarget.host) &&
|
||||
!isLikelyIpv6Host(proxyPassTarget.host)
|
||||
? proxyPassTarget.host
|
||||
: null),
|
||||
targetPort: proxyPassTarget.port,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(domains.values());
|
||||
}
|
||||
|
||||
async function listTraefikDomains(server: Server): Promise<DiscoveredDomain[]> {
|
||||
const inspectOutput = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
'ids=$(docker ps -q 2>/dev/null); if [ -n "$ids" ]; then docker inspect $ids 2>/dev/null; fi',
|
||||
)}`,
|
||||
domainDiscoveryTimeout(DOMAIN_DISCOVERY_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
if (!inspectOutput.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let containers: DockerInspectLabels[] = [];
|
||||
try {
|
||||
containers = JSON.parse(inspectOutput.stdout) as DockerInspectLabels[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const domains = new Map<string, DiscoveredDomain>();
|
||||
|
||||
for (const container of containers) {
|
||||
const labels = container.Config?.Labels ?? {};
|
||||
const traefikEnabled = `${labels["traefik.enable"] ?? ""}`.toLowerCase();
|
||||
const targetPort = extractTraefikTargetPort(labels);
|
||||
const containerName = normalizeContainerName(container.Name);
|
||||
const routerRules = Object.entries(labels)
|
||||
.filter(([key, value]) => {
|
||||
if (!value) return false;
|
||||
return /^traefik\.(http|tcp)\.routers\.[^.]+\.rule$/i.test(key);
|
||||
})
|
||||
.map(([, value]) => value);
|
||||
|
||||
if (traefikEnabled === "false" || routerRules.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const rule of routerRules) {
|
||||
for (const domainName of parseHostRuleDomains(rule)) {
|
||||
addDiscoveredDomain(
|
||||
domains,
|
||||
domainName,
|
||||
"TRAEFIK",
|
||||
"TRAEFIK",
|
||||
server.ip,
|
||||
false,
|
||||
{
|
||||
dockerId: container.Id ?? null,
|
||||
containerName,
|
||||
targetPort,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(domains.values());
|
||||
}
|
||||
|
||||
async function listCaddyAdminApiDomains(
|
||||
server: Server,
|
||||
): Promise<DiscoveredDomain[]> {
|
||||
const hostConfig = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
[
|
||||
"if command -v curl >/dev/null 2>&1; then",
|
||||
" curl --max-time 2 -fsS http://127.0.0.1:2019/config || curl --max-time 2 -fsS http://localhost:2019/config || true",
|
||||
"elif command -v wget >/dev/null 2>&1; then",
|
||||
" wget -T 2 -qO- http://127.0.0.1:2019/config || wget -T 2 -qO- http://localhost:2019/config || true",
|
||||
"fi",
|
||||
].join(" "),
|
||||
)}`,
|
||||
domainDiscoveryTimeout(DOMAIN_FILE_DISCOVERY_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const domains = new Map<string, DiscoveredDomain>();
|
||||
|
||||
for (const domainName of extractCaddyJsonDomains(hostConfig.stdout)) {
|
||||
addDiscoveredDomain(domains, domainName, "CADDY", "CADDY_ADMIN", server.ip);
|
||||
}
|
||||
|
||||
return Array.from(domains.values());
|
||||
}
|
||||
|
||||
async function listCaddyDomains(server: Server): Promise<DiscoveredDomain[]> {
|
||||
const caddyfileOutput = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
[
|
||||
"for file in /etc/caddy/Caddyfile /usr/local/etc/caddy/Caddyfile /opt/*/Caddyfile /srv/*/Caddyfile; do",
|
||||
' if [ -f "$file" ]; then',
|
||||
' printf "__FILE__:%s\\n" "$file";',
|
||||
' cat "$file";',
|
||||
' printf "\\n__END_FILE__\\n";',
|
||||
" fi",
|
||||
"done",
|
||||
].join(" "),
|
||||
)}`,
|
||||
domainDiscoveryTimeout(DOMAIN_FILE_DISCOVERY_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const caddyJsonOutput = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
[
|
||||
"for file in /etc/caddy/*.json /usr/local/etc/caddy/*.json /opt/*/caddy*.json /srv/*/caddy*.json; do",
|
||||
' if [ -f "$file" ]; then',
|
||||
' printf "__FILE__:%s\\n" "$file";',
|
||||
' cat "$file";',
|
||||
' printf "\\n__END_FILE__\\n";',
|
||||
" fi",
|
||||
"done",
|
||||
].join(" "),
|
||||
)}`,
|
||||
domainDiscoveryTimeout(DOMAIN_FILE_DISCOVERY_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const inspectOutput = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
'ids=$(docker ps -q 2>/dev/null); if [ -n "$ids" ]; then docker inspect $ids 2>/dev/null; fi',
|
||||
)}`,
|
||||
domainDiscoveryTimeout(DOMAIN_DISCOVERY_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const caddyAdminDomains = await listCaddyAdminApiDomains(server);
|
||||
|
||||
const domains = new Map<string, DiscoveredDomain>();
|
||||
|
||||
for (const chunk of caddyfileOutput.stdout.split("__END_FILE__")) {
|
||||
for (const domainName of extractCaddyfileDomains(chunk)) {
|
||||
addDiscoveredDomain(domains, domainName, "CADDY", "CADDY", server.ip);
|
||||
}
|
||||
}
|
||||
|
||||
for (const chunk of caddyJsonOutput.stdout.split("__END_FILE__")) {
|
||||
for (const domainName of extractCaddyJsonDomains(chunk)) {
|
||||
addDiscoveredDomain(domains, domainName, "CADDY", "CADDY", server.ip);
|
||||
}
|
||||
}
|
||||
|
||||
for (const discovered of caddyAdminDomains) {
|
||||
addDiscoveredDomain(
|
||||
domains,
|
||||
discovered.name,
|
||||
discovered.proxy,
|
||||
discovered.discoverySource,
|
||||
discovered.value ?? server.ip,
|
||||
discovered.sslEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
let containers: DockerInspectLabels[] = [];
|
||||
try {
|
||||
containers = inspectOutput.stdout.trim()
|
||||
? (JSON.parse(inspectOutput.stdout) as DockerInspectLabels[])
|
||||
: [];
|
||||
} catch {
|
||||
containers = [];
|
||||
}
|
||||
|
||||
for (const container of containers) {
|
||||
const labels = container.Config?.Labels ?? {};
|
||||
const imageName = `${container.Config?.Image ?? ""}`.toLowerCase();
|
||||
const containerName = `${container.Name ?? ""}`.toLowerCase();
|
||||
const targetPort = extractCaddyTargetPort(labels);
|
||||
const isCaddyContainer =
|
||||
imageName.includes("caddy") || containerName.includes("caddy");
|
||||
|
||||
for (const [key, value] of Object.entries(labels)) {
|
||||
if (!value) continue;
|
||||
|
||||
if (key === "caddy" || /^caddy_\d+$/i.test(key)) {
|
||||
for (const token of value.split(/[\s,]+/)) {
|
||||
addDiscoveredDomain(
|
||||
domains,
|
||||
token,
|
||||
"CADDY",
|
||||
"CADDY",
|
||||
server.ip,
|
||||
false,
|
||||
{
|
||||
dockerId: container.Id ?? null,
|
||||
containerName,
|
||||
targetPort,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCaddyContainer || !container.Id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const containerConfig = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
[
|
||||
`for file in /etc/caddy/Caddyfile /config/caddy/Caddyfile /etc/caddy/config.json /config/caddy/config.json; do`,
|
||||
` if docker exec ${container.Id} test -f \"$file\" 2>/dev/null; then`,
|
||||
' printf "__FILE__:%s\\n" "$file";',
|
||||
` docker exec ${container.Id} cat \"$file\" 2>/dev/null;`,
|
||||
' printf "\\n__END_FILE__\\n";',
|
||||
" fi",
|
||||
"done",
|
||||
].join(" "),
|
||||
)}`,
|
||||
domainDiscoveryTimeout(DOMAIN_FILE_DISCOVERY_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
for (const chunk of containerConfig.stdout.split("__END_FILE__")) {
|
||||
for (const domainName of extractCaddyfileDomains(chunk)) {
|
||||
addDiscoveredDomain(domains, domainName, "CADDY", "CADDY", server.ip);
|
||||
}
|
||||
|
||||
for (const domainName of extractCaddyJsonDomains(chunk)) {
|
||||
addDiscoveredDomain(domains, domainName, "CADDY", "CADDY", server.ip);
|
||||
}
|
||||
}
|
||||
|
||||
const containerAdminConfig = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
[
|
||||
`docker exec ${container.Id} sh -lc`,
|
||||
escapeShellArg(
|
||||
[
|
||||
"if command -v curl >/dev/null 2>&1; then",
|
||||
" curl --max-time 2 -fsS http://127.0.0.1:2019/config || true",
|
||||
"elif command -v wget >/dev/null 2>&1; then",
|
||||
" wget -T 2 -qO- http://127.0.0.1:2019/config || true",
|
||||
"fi",
|
||||
].join(" "),
|
||||
),
|
||||
].join(" "),
|
||||
)} 2>/dev/null`,
|
||||
domainDiscoveryTimeout(DOMAIN_FILE_DISCOVERY_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
for (const domainName of extractCaddyJsonDomains(
|
||||
containerAdminConfig.stdout,
|
||||
)) {
|
||||
addDiscoveredDomain(
|
||||
domains,
|
||||
domainName,
|
||||
"CADDY",
|
||||
"CADDY_ADMIN",
|
||||
server.ip,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(domains.values());
|
||||
}
|
||||
|
||||
export async function listServerDomains(
|
||||
server: Server,
|
||||
): Promise<DiscoveredDomain[]> {
|
||||
const domains = new Map<string, DiscoveredDomain>();
|
||||
|
||||
const [nginxDomains, traefikDomains, caddyDomains] = await Promise.all([
|
||||
listNginxDomains(server),
|
||||
listTraefikDomains(server),
|
||||
listCaddyDomains(server),
|
||||
]);
|
||||
|
||||
for (const discovered of [
|
||||
...nginxDomains,
|
||||
...traefikDomains,
|
||||
...caddyDomains,
|
||||
]) {
|
||||
addDiscoveredDomain(
|
||||
domains,
|
||||
discovered.name,
|
||||
discovered.proxy,
|
||||
discovered.discoverySource,
|
||||
discovered.value ?? server.ip,
|
||||
discovered.sslEnabled,
|
||||
{
|
||||
dockerId: discovered.dockerId,
|
||||
containerName: discovered.containerName,
|
||||
targetPort: discovered.targetPort,
|
||||
sourceConfigPath: discovered.sourceConfigPath,
|
||||
managedByDoktainer: discovered.managedByDoktainer,
|
||||
managedConfigMode: discovered.managedConfigMode,
|
||||
managedPrimaryDomain: discovered.managedPrimaryDomain,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from(domains.values()).sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Server } from "@prisma/client";
|
||||
|
||||
import { exec, execStrict } from "./commands";
|
||||
import type { ServerPlatformInfo } from "./platform";
|
||||
import { detectServerPlatform } from "./platform";
|
||||
import { privilegedCommand } from "./internal/privilege";
|
||||
import { escapeShellArg } from "./internal/shell";
|
||||
import { hasCommand } from "./internal/system";
|
||||
|
||||
export interface BannedIP {
|
||||
ip: string;
|
||||
jail: string;
|
||||
bannedAt: string;
|
||||
}
|
||||
|
||||
export interface Fail2banStatus {
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
bannedIPs: BannedIP[];
|
||||
}
|
||||
|
||||
export async function getFail2banStatus(
|
||||
server: Server,
|
||||
): Promise<Fail2banStatus> {
|
||||
const installed = await hasCommand(server, "fail2ban-client");
|
||||
if (!installed) {
|
||||
return { enabled: false, installed: false, bannedIPs: [] };
|
||||
}
|
||||
|
||||
const statusResult = await exec(
|
||||
server,
|
||||
`${privilegedCommand(server, "systemctl is-active fail2ban")} 2>/dev/null || echo "inactive"`,
|
||||
);
|
||||
const enabled = statusResult.stdout.trim() === "active";
|
||||
|
||||
const bannedIPs: BannedIP[] = [];
|
||||
if (enabled) {
|
||||
const banned = await exec(
|
||||
server,
|
||||
`${privilegedCommand(server, "fail2ban-client status sshd")} 2>/dev/null | grep 'Banned IP list' | sed 's/.*Banned IP list://g'`,
|
||||
);
|
||||
const ips = banned.stdout.trim().split(/\s+/).filter(Boolean);
|
||||
for (const ip of ips) {
|
||||
bannedIPs.push({ ip, jail: "sshd", bannedAt: new Date().toISOString() });
|
||||
}
|
||||
}
|
||||
return { enabled, installed: true, bannedIPs };
|
||||
}
|
||||
|
||||
export async function installFail2ban(server: Server): Promise<void> {
|
||||
const platform = await detectServerPlatform(server);
|
||||
|
||||
if (!platform.packageManager) {
|
||||
throw new Error(
|
||||
`Fail2ban install is not supported on ${platform.distro ?? server.name}: no supported package manager found`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!platform.sudoNonInteractive && server.username !== "root") {
|
||||
throw new Error(
|
||||
`Fail2ban install requires non-interactive sudo access on server ${server.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
const installCommands: Record<
|
||||
NonNullable<ServerPlatformInfo["packageManager"]>,
|
||||
string
|
||||
> = {
|
||||
"apt-get": "apt-get update && apt-get install -y fail2ban",
|
||||
dnf: "dnf install -y fail2ban",
|
||||
yum: "yum install -y epel-release && yum install -y fail2ban",
|
||||
zypper: "zypper --non-interactive install fail2ban",
|
||||
apk: "apk add fail2ban",
|
||||
};
|
||||
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(installCommands[platform.packageManager])}`,
|
||||
),
|
||||
);
|
||||
await enableFail2ban(server);
|
||||
}
|
||||
|
||||
export async function enableFail2ban(server: Server): Promise<void> {
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(server, "systemctl enable --now fail2ban"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function disableFail2ban(server: Server): Promise<void> {
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(server, "systemctl disable --now fail2ban"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function unbanIP(
|
||||
server: Server,
|
||||
ip: string,
|
||||
jail = "sshd",
|
||||
): Promise<void> {
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(server, `fail2ban-client set ${jail} unbanip ${ip}`),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Server } from "@prisma/client";
|
||||
|
||||
import { exec, execStrict } from "./commands";
|
||||
import { privilegedCommand } from "./internal/privilege";
|
||||
|
||||
export interface FirewallRule {
|
||||
number: number;
|
||||
rule: string;
|
||||
action: string;
|
||||
direction: string;
|
||||
from: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export async function getFirewallStatus(
|
||||
server: Server,
|
||||
): Promise<{ enabled: boolean; rules: FirewallRule[] }> {
|
||||
const status = await exec(
|
||||
server,
|
||||
`${privilegedCommand(server, "ufw status numbered")} 2>/dev/null || echo "inactive"`,
|
||||
);
|
||||
const output = status.stdout;
|
||||
const enabled =
|
||||
!output.includes("inactive") && !output.includes("Status: inactive");
|
||||
|
||||
const rules: FirewallRule[] = [];
|
||||
const lines = output.split("\n");
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\[\s*(\d+)\]\s+(.+)$/);
|
||||
if (match) {
|
||||
const columns = match[2]
|
||||
.split(/\s{2,}/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (columns.length < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const actionParts = columns[1].split(/\s+/).filter(Boolean);
|
||||
rules.push({
|
||||
number: Number(match[1]),
|
||||
rule: columns[0],
|
||||
action: (actionParts[0] || "").toUpperCase(),
|
||||
direction: (actionParts[1] || "").toUpperCase(),
|
||||
from: columns.slice(2).join(" "),
|
||||
description: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
return { enabled, rules };
|
||||
}
|
||||
|
||||
export async function enableFirewall(server: Server): Promise<void> {
|
||||
await execStrict(server, privilegedCommand(server, "ufw --force enable"));
|
||||
}
|
||||
|
||||
export async function disableFirewall(server: Server): Promise<void> {
|
||||
await execStrict(server, privilegedCommand(server, "ufw --force disable"));
|
||||
}
|
||||
|
||||
export async function addFirewallRule(
|
||||
server: Server,
|
||||
rule: string,
|
||||
action: "allow" | "deny",
|
||||
from?: string,
|
||||
): Promise<void> {
|
||||
const fromPart = from && from !== "Anywhere" ? `from ${from} ` : "";
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(server, `ufw ${action} ${fromPart}${rule}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteFirewallRule(
|
||||
server: Server,
|
||||
ruleNum: number,
|
||||
): Promise<void> {
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(server, `ufw --force delete ${ruleNum}`),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export * from "./connection";
|
||||
export * from "./commands";
|
||||
export * from "./metrics";
|
||||
export * from "./platform";
|
||||
export * from "./server-config";
|
||||
export * from "./web-stack";
|
||||
export * from "./docker-engine";
|
||||
export * from "./docker-networks";
|
||||
export * from "./docker-containers";
|
||||
export * from "./firewall";
|
||||
export * from "./fail2ban";
|
||||
export * from "./domains";
|
||||
export * from "./ssl";
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Server } from "@prisma/client";
|
||||
|
||||
import { execStrict } from "../commands";
|
||||
import { escapeShellArg } from "./shell";
|
||||
import { privilegedCommand } from "./privilege";
|
||||
|
||||
type WebStackAction = "install" | "upgrade" | "reinstall" | "remove";
|
||||
|
||||
const certbotBinaryCandidates = [
|
||||
"certbot",
|
||||
"/snap/bin/certbot",
|
||||
"/usr/bin/certbot",
|
||||
"/usr/local/bin/certbot",
|
||||
];
|
||||
|
||||
export function buildCertbotResolveScript(): string {
|
||||
const candidates = certbotBinaryCandidates.join(" ");
|
||||
|
||||
return [
|
||||
'resolved=""',
|
||||
`for candidate in ${candidates}; do`,
|
||||
' if command -v "$candidate" >/dev/null 2>&1; then',
|
||||
' resolved=$(command -v "$candidate")',
|
||||
" break",
|
||||
" fi",
|
||||
' if [ -x "$candidate" ]; then',
|
||||
' resolved="$candidate"',
|
||||
" break",
|
||||
" fi",
|
||||
"done",
|
||||
'if [ -z "$resolved" ] && command -v python3 >/dev/null 2>&1; then',
|
||||
" if python3 -m certbot --version >/dev/null 2>&1; then",
|
||||
' resolved="python3 -m certbot"',
|
||||
" fi",
|
||||
"fi",
|
||||
'if [ -n "$resolved" ]; then',
|
||||
' printf "%s\\n" "$resolved"',
|
||||
"fi",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildCertbotCommandTest(): string {
|
||||
return [
|
||||
buildCertbotResolveScript(),
|
||||
'resolved=$(printf "%s" "$resolved")',
|
||||
'[ -n "$resolved" ]',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildCertbotVersionCommand(): string {
|
||||
return [
|
||||
`resolved=$(${buildCertbotResolveScript()})`,
|
||||
'if [ -n "$resolved" ]; then',
|
||||
' eval "$resolved --version" 2>/dev/null | head -n1',
|
||||
"fi",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildAptCertbotCommand(
|
||||
action: Exclude<WebStackAction, "remove">,
|
||||
): string {
|
||||
const aptRepairPrefix = [
|
||||
"export DEBIAN_FRONTEND=noninteractive",
|
||||
"dpkg --configure -a || true",
|
||||
"apt-get install -f -y || true",
|
||||
"apt-get update",
|
||||
];
|
||||
|
||||
const installStep =
|
||||
action === "upgrade"
|
||||
? [
|
||||
"if apt-cache show certbot >/dev/null 2>&1; then",
|
||||
" apt-get install --only-upgrade -y certbot || apt-get install --only-upgrade -y python3-certbot",
|
||||
"elif apt-cache show python3-certbot >/dev/null 2>&1; then",
|
||||
" apt-get install --only-upgrade -y python3-certbot",
|
||||
"else",
|
||||
" apt-get install --only-upgrade -y certbot || true",
|
||||
"fi",
|
||||
]
|
||||
: action === "reinstall"
|
||||
? [
|
||||
"if apt-cache show certbot >/dev/null 2>&1; then",
|
||||
" apt-get install --reinstall -y certbot || apt-get install --reinstall -y python3-certbot",
|
||||
"elif apt-cache show python3-certbot >/dev/null 2>&1; then",
|
||||
" apt-get install --reinstall -y python3-certbot",
|
||||
"else",
|
||||
" apt-get install --reinstall -y certbot || true",
|
||||
"fi",
|
||||
]
|
||||
: [
|
||||
"if apt-cache show certbot >/dev/null 2>&1; then",
|
||||
" apt-get install -y certbot || apt-get install -y python3-certbot",
|
||||
"elif apt-cache show python3-certbot >/dev/null 2>&1; then",
|
||||
" apt-get install -y python3-certbot",
|
||||
"else",
|
||||
" apt-get install -y certbot || true",
|
||||
"fi",
|
||||
];
|
||||
|
||||
return [
|
||||
...aptRepairPrefix,
|
||||
...installStep,
|
||||
`${buildCertbotCommandTest()} >/dev/null 2>&1`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildCertbotEmailFlag(domainName: string): string {
|
||||
const configuredEmail = process.env.CERTBOT_EMAIL?.trim();
|
||||
if (configuredEmail) {
|
||||
return `--email ${escapeShellArg(configuredEmail)}`;
|
||||
}
|
||||
|
||||
const fallbackEmail = `admin@${domainName}`;
|
||||
return `--email ${escapeShellArg(fallbackEmail)}`;
|
||||
}
|
||||
|
||||
export async function ensureCertbotInstalled(server: Server): Promise<void> {
|
||||
await resolveCertbotCommand(server);
|
||||
}
|
||||
|
||||
export async function resolveCertbotCommand(server: Server): Promise<string> {
|
||||
try {
|
||||
const result = await execStrict(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(`${buildCertbotResolveScript()} | grep -q . && ${buildCertbotResolveScript()}`)}`,
|
||||
),
|
||||
);
|
||||
return result.trim().split(/\r?\n/).find(Boolean) ?? "certbot";
|
||||
} catch {
|
||||
throw new Error(
|
||||
`certbot is not installed or not executable on server ${server.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import type { SSHExecCommandResponse } from "node-ssh";
|
||||
|
||||
import { exec } from "../commands";
|
||||
import { detectServerPlatform } from "../platform";
|
||||
|
||||
function shouldRetryDockerWithSudo(output: string): boolean {
|
||||
const lower = output.toLowerCase();
|
||||
return (
|
||||
lower.includes("permission denied") ||
|
||||
lower.includes("docker.sock") ||
|
||||
lower.includes("you must be root")
|
||||
);
|
||||
}
|
||||
|
||||
export async function execDocker(
|
||||
server: Server,
|
||||
command: string,
|
||||
options: { timeoutMs?: number; queueTimeoutMs?: number } = {},
|
||||
): Promise<SSHExecCommandResponse> {
|
||||
const directResult = await exec(server, command, options);
|
||||
if (directResult.code === 0 || server.username === "root") {
|
||||
return directResult;
|
||||
}
|
||||
|
||||
const output = `${directResult.stderr || ""}\n${directResult.stdout || ""}`;
|
||||
if (!shouldRetryDockerWithSudo(output)) {
|
||||
return directResult;
|
||||
}
|
||||
|
||||
const platform = await detectServerPlatform(server);
|
||||
if (!platform.sudoNonInteractive) {
|
||||
return directResult;
|
||||
}
|
||||
|
||||
const sudoResult = await exec(server, `sudo -n ${command}`, options);
|
||||
return sudoResult.code === 0 ? sudoResult : directResult;
|
||||
}
|
||||
|
||||
export async function execDockerStrict(
|
||||
server: Server,
|
||||
command: string,
|
||||
options: { timeoutMs?: number; queueTimeoutMs?: number } = {},
|
||||
): Promise<string> {
|
||||
const result = await execDocker(server, command, options);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`Command failed: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Server } from "@prisma/client";
|
||||
|
||||
export function privilegedCommand(server: Server, command: string): string {
|
||||
return server.username === "root" ? command : `sudo ${command}`;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function escapeShellArg(value: string): string {
|
||||
return `'${value.replace(/'/g, `"'"'"`)}'`;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import type { SSHExecCommandResponse } from "node-ssh";
|
||||
|
||||
import { exec } from "../commands";
|
||||
import { escapeShellArg } from "./shell";
|
||||
|
||||
type CommandExecutor = (
|
||||
server: Server,
|
||||
command: string,
|
||||
) => Promise<SSHExecCommandResponse>;
|
||||
|
||||
export async function hasCommand(
|
||||
server: Server,
|
||||
command: string,
|
||||
executor: CommandExecutor = exec,
|
||||
): Promise<boolean> {
|
||||
const result = await executor(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(`command -v ${command} >/dev/null 2>&1`)}`,
|
||||
);
|
||||
return result.code === 0;
|
||||
}
|
||||
|
||||
export type { CommandExecutor };
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import type { NodeSSH } from "node-ssh";
|
||||
|
||||
import { getConnection, withIsolatedConnection } from "./connection";
|
||||
|
||||
export interface SystemMetrics {
|
||||
cpuPct: number;
|
||||
ramPct: number;
|
||||
diskPct: number;
|
||||
ramUsed: bigint;
|
||||
ramTotal: bigint;
|
||||
diskUsed: bigint;
|
||||
diskTotal: bigint;
|
||||
networkRxBps: bigint;
|
||||
networkTxBps: bigint;
|
||||
uptimeSec: bigint;
|
||||
os: string;
|
||||
}
|
||||
|
||||
const NETWORK_BYTES_COMMAND =
|
||||
"awk -F'[: ]+' 'NR>2 && $1 != \"lo\" { rx += $3; tx += $11 } END { print rx+0, tx+0 }' /proc/net/dev";
|
||||
const CPU_STAT_COMMAND = "awk '/^cpu / {print}' /proc/stat";
|
||||
const MEMORY_COMMAND =
|
||||
"awk '/^MemTotal:/ { total=$2 * 1024 } /^MemAvailable:/ { available=$2 * 1024 } END { print total+0, total-available }' /proc/meminfo";
|
||||
|
||||
function parseBigIntPair(output: string): [bigint, bigint] {
|
||||
const [leftRaw = "0", rightRaw = "0"] = output.trim().split(/\s+/, 2);
|
||||
|
||||
return [BigInt(leftRaw || "0"), BigInt(rightRaw || "0")];
|
||||
}
|
||||
|
||||
function diffCounter(start: bigint, end: bigint): bigint {
|
||||
return end >= start ? end - start : 0n;
|
||||
}
|
||||
|
||||
function parseCpuStat(output: string): bigint[] {
|
||||
return output
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(1)
|
||||
.map((value) => BigInt(value || "0"));
|
||||
}
|
||||
|
||||
function sumCpuFields(fields: bigint[]): bigint {
|
||||
return fields.reduce((total, value) => total + value, 0n);
|
||||
}
|
||||
|
||||
function calculateCpuPct(start: bigint[], end: bigint[]): number {
|
||||
const startTotal = sumCpuFields(start);
|
||||
const endTotal = sumCpuFields(end);
|
||||
const totalDiff = endTotal - startTotal;
|
||||
if (totalDiff <= 0n) return 0;
|
||||
|
||||
const startIdle = (start[3] ?? 0n) + (start[4] ?? 0n);
|
||||
const endIdle = (end[3] ?? 0n) + (end[4] ?? 0n);
|
||||
const idleDiff = endIdle >= startIdle ? endIdle - startIdle : 0n;
|
||||
const busyDiff = totalDiff > idleDiff ? totalDiff - idleDiff : 0n;
|
||||
|
||||
return Math.min(100, Math.max(0, (Number(busyDiff) / Number(totalDiff)) * 100));
|
||||
}
|
||||
|
||||
async function collectMetricsFromConnection(
|
||||
ssh: NodeSSH,
|
||||
): Promise<SystemMetrics> {
|
||||
// Parallel execution for speed
|
||||
const [cpuStartOut, memOut, diskOut, uptimeOut, osOut, networkStartOut] =
|
||||
await Promise.all([
|
||||
ssh.execCommand(CPU_STAT_COMMAND),
|
||||
ssh.execCommand(MEMORY_COMMAND),
|
||||
ssh.execCommand("df -B1 / | awk 'NR==2{print $2, $3}'"),
|
||||
ssh.execCommand("cat /proc/uptime | awk '{print int($1)}'"),
|
||||
ssh.execCommand(
|
||||
"cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2 | tr -d '\"'",
|
||||
),
|
||||
ssh.execCommand(NETWORK_BYTES_COMMAND),
|
||||
]);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const [cpuEndOut, networkEndOut] = await Promise.all([
|
||||
ssh.execCommand(CPU_STAT_COMMAND),
|
||||
ssh.execCommand(NETWORK_BYTES_COMMAND),
|
||||
]);
|
||||
|
||||
const cpuPct = calculateCpuPct(
|
||||
parseCpuStat(cpuStartOut.stdout),
|
||||
parseCpuStat(cpuEndOut.stdout),
|
||||
);
|
||||
|
||||
const [ramTotal, ramUsed] = memOut.stdout.trim().split(" ").map(BigInt);
|
||||
|
||||
const [diskTotal, diskUsed] = diskOut.stdout.trim().split(" ").map(BigInt);
|
||||
const [networkRxStart, networkTxStart] = parseBigIntPair(
|
||||
networkStartOut.stdout,
|
||||
);
|
||||
const [networkRxEnd, networkTxEnd] = parseBigIntPair(networkEndOut.stdout);
|
||||
|
||||
const uptimeSec = BigInt(uptimeOut.stdout.trim() || "0");
|
||||
const os = osOut.stdout.trim() || "Linux";
|
||||
|
||||
const ramPct = ramTotal > 0n ? Number((ramUsed * 100n) / ramTotal) : 0;
|
||||
const diskPct = diskTotal > 0n ? Number((diskUsed * 100n) / diskTotal) : 0;
|
||||
const networkRxBps = diffCounter(networkRxStart, networkRxEnd);
|
||||
const networkTxBps = diffCounter(networkTxStart, networkTxEnd);
|
||||
|
||||
return {
|
||||
cpuPct,
|
||||
ramPct,
|
||||
diskPct,
|
||||
ramUsed,
|
||||
ramTotal,
|
||||
diskUsed,
|
||||
diskTotal,
|
||||
networkRxBps,
|
||||
networkTxBps,
|
||||
uptimeSec,
|
||||
os,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect CPU, RAM, Disk, Uptime metrics via SSH
|
||||
*/
|
||||
export async function collectMetrics(
|
||||
server: Server,
|
||||
options?: { isolated?: boolean },
|
||||
): Promise<SystemMetrics> {
|
||||
if (options?.isolated) {
|
||||
return withIsolatedConnection(server, collectMetricsFromConnection);
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server);
|
||||
return collectMetricsFromConnection(ssh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format uptime seconds into "Xd Yh" string
|
||||
*/
|
||||
export function formatUptime(seconds: bigint): string {
|
||||
const s = Number(seconds);
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Server } from "@prisma/client";
|
||||
|
||||
import { exec, execIsolated } from "./commands";
|
||||
import { hasCommand, type CommandExecutor } from "./internal/system";
|
||||
|
||||
export interface ServerPlatformInfo {
|
||||
distro: string | null;
|
||||
packageManager: "apt-get" | "dnf" | "yum" | "zypper" | "apk" | null;
|
||||
sudoNonInteractive: boolean;
|
||||
supportedForFail2banInstall: boolean;
|
||||
}
|
||||
|
||||
export interface DockerRuntimeStatus {
|
||||
installed: boolean;
|
||||
daemonRunning: boolean;
|
||||
available: boolean;
|
||||
version: string | null;
|
||||
reason: string | null;
|
||||
canInstall: boolean;
|
||||
probeFailed?: boolean;
|
||||
platform: ServerPlatformInfo;
|
||||
}
|
||||
|
||||
export interface RuntimeProbeOptions {
|
||||
isolated?: boolean;
|
||||
timeoutMs?: number;
|
||||
queueTimeoutMs?: number;
|
||||
platform?: ServerPlatformInfo;
|
||||
}
|
||||
|
||||
const DEFAULT_RUNTIME_PROBE_TIMEOUT_MS = 8_000;
|
||||
|
||||
export const UNKNOWN_SERVER_PLATFORM: ServerPlatformInfo = {
|
||||
distro: null,
|
||||
packageManager: null,
|
||||
sudoNonInteractive: false,
|
||||
supportedForFail2banInstall: false,
|
||||
};
|
||||
|
||||
export function createDockerProbeFailureStatus(
|
||||
reason: string,
|
||||
platform: ServerPlatformInfo = UNKNOWN_SERVER_PLATFORM,
|
||||
): DockerRuntimeStatus {
|
||||
return {
|
||||
installed: false,
|
||||
daemonRunning: false,
|
||||
available: false,
|
||||
version: null,
|
||||
reason,
|
||||
canInstall: false,
|
||||
probeFailed: true,
|
||||
platform,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCommandExecutor(
|
||||
options: RuntimeProbeOptions = {},
|
||||
): CommandExecutor {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_RUNTIME_PROBE_TIMEOUT_MS;
|
||||
const queueTimeoutMs = options.queueTimeoutMs ?? timeoutMs;
|
||||
|
||||
if (!options.isolated) {
|
||||
return (server, command) =>
|
||||
exec(server, command, { timeoutMs, queueTimeoutMs });
|
||||
}
|
||||
|
||||
return (server, command) =>
|
||||
execIsolated(server, command, { timeoutMs });
|
||||
}
|
||||
|
||||
export async function detectServerPlatform(
|
||||
server: Server,
|
||||
options: RuntimeProbeOptions = {},
|
||||
): Promise<ServerPlatformInfo> {
|
||||
const executor = resolveCommandExecutor(options);
|
||||
const [osRelease, aptGet, dnf, yum, zypper, apk, sudoCheck] =
|
||||
await Promise.all([
|
||||
executor(
|
||||
server,
|
||||
"cat /etc/os-release 2>/dev/null | grep '^PRETTY_NAME=' | cut -d= -f2- | tr -d '\"'",
|
||||
),
|
||||
hasCommand(server, "apt-get", executor),
|
||||
hasCommand(server, "dnf", executor),
|
||||
hasCommand(server, "yum", executor),
|
||||
hasCommand(server, "zypper", executor),
|
||||
hasCommand(server, "apk", executor),
|
||||
server.username === "root"
|
||||
? Promise.resolve(true)
|
||||
: executor(server, "sudo -n true >/dev/null 2>&1"),
|
||||
]);
|
||||
|
||||
const packageManager = aptGet
|
||||
? "apt-get"
|
||||
: dnf
|
||||
? "dnf"
|
||||
: yum
|
||||
? "yum"
|
||||
: zypper
|
||||
? "zypper"
|
||||
: apk
|
||||
? "apk"
|
||||
: null;
|
||||
|
||||
const sudoNonInteractive =
|
||||
server.username === "root"
|
||||
? true
|
||||
: !!sudoCheck && typeof sudoCheck !== "boolean"
|
||||
? sudoCheck.code === 0
|
||||
: false;
|
||||
|
||||
return {
|
||||
distro: osRelease.stdout.trim() || null,
|
||||
packageManager,
|
||||
sudoNonInteractive,
|
||||
supportedForFail2banInstall:
|
||||
!!packageManager && (server.username === "root" || sudoNonInteractive),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDockerError(output: string): string {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) {
|
||||
return "Docker is not ready on this server";
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.includes("cannot connect to the docker daemon")) {
|
||||
return "Docker is installed but the daemon is not running";
|
||||
}
|
||||
|
||||
if (lower.includes("permission denied")) {
|
||||
return "Docker is installed but the current SSH user cannot access the Docker daemon";
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export async function getDockerRuntimeStatus(
|
||||
server: Server,
|
||||
options: RuntimeProbeOptions = {},
|
||||
): Promise<DockerRuntimeStatus> {
|
||||
const executor = resolveCommandExecutor(options);
|
||||
const platform =
|
||||
options.platform ?? (await detectServerPlatform(server, options));
|
||||
const installed = await hasCommand(server, "docker", executor);
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
installed: false,
|
||||
daemonRunning: false,
|
||||
available: false,
|
||||
version: null,
|
||||
reason: "Docker CLI was not found on this server",
|
||||
canInstall:
|
||||
!!platform.packageManager &&
|
||||
(server.username === "root" || platform.sudoNonInteractive),
|
||||
platform,
|
||||
};
|
||||
}
|
||||
|
||||
const directResult = await executor(
|
||||
server,
|
||||
"docker info --format '{{.ServerVersion}}' 2>&1",
|
||||
);
|
||||
|
||||
if (directResult.code === 0) {
|
||||
return {
|
||||
installed: true,
|
||||
daemonRunning: true,
|
||||
available: true,
|
||||
version: directResult.stdout.trim() || null,
|
||||
reason: null,
|
||||
canInstall: false,
|
||||
platform,
|
||||
};
|
||||
}
|
||||
|
||||
if (server.username !== "root" && platform.sudoNonInteractive) {
|
||||
const sudoResult = await executor(
|
||||
server,
|
||||
`sudo -n docker info --format '{{.ServerVersion}}' 2>&1`,
|
||||
);
|
||||
|
||||
if (sudoResult.code === 0) {
|
||||
return {
|
||||
installed: true,
|
||||
daemonRunning: true,
|
||||
available: true,
|
||||
version: sudoResult.stdout.trim() || null,
|
||||
reason: null,
|
||||
canInstall: false,
|
||||
platform,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
daemonRunning: false,
|
||||
available: false,
|
||||
version: null,
|
||||
reason: normalizeDockerError(sudoResult.stderr || sudoResult.stdout),
|
||||
canInstall: false,
|
||||
platform,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
daemonRunning: false,
|
||||
available: false,
|
||||
version: null,
|
||||
reason: normalizeDockerError(directResult.stderr || directResult.stdout),
|
||||
canInstall: false,
|
||||
platform,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import { Server } from "@prisma/client";
|
||||
|
||||
import { closeConnection } from "./connection";
|
||||
import { exec, execStrict } from "./commands";
|
||||
import type { DockerRuntimeStatus, ServerPlatformInfo } from "./platform";
|
||||
import {
|
||||
createDockerProbeFailureStatus,
|
||||
detectServerPlatform,
|
||||
getDockerRuntimeStatus,
|
||||
UNKNOWN_SERVER_PLATFORM,
|
||||
} from "./platform";
|
||||
import type { ServerWebCapability } from "./web-stack";
|
||||
import {
|
||||
getInspectableHostServices,
|
||||
inspectWebServerCapability,
|
||||
} from "./web-stack";
|
||||
import { escapeShellArg } from "./internal/shell";
|
||||
import { privilegedCommand } from "./internal/privilege";
|
||||
|
||||
const CONFIG_FAST_TIMEOUT_MS = 8_000;
|
||||
const CONFIG_LIST_TIMEOUT_MS = 12_000;
|
||||
const CONFIG_DOCKER_TIMEOUT_MS = 18_000;
|
||||
const CONFIG_WEB_STACK_TIMEOUT_MS = 18_000;
|
||||
|
||||
function configCommandTimeout(timeoutMs: number) {
|
||||
return { timeoutMs, queueTimeoutMs: timeoutMs };
|
||||
}
|
||||
|
||||
export interface ServerSystemUser {
|
||||
username: string;
|
||||
uid: number | null;
|
||||
gid: number | null;
|
||||
home: string | null;
|
||||
shell: string | null;
|
||||
groups: string[];
|
||||
isRoot: boolean;
|
||||
isSshUser: boolean;
|
||||
}
|
||||
|
||||
export interface ServerServiceStatus {
|
||||
name: string;
|
||||
active: string;
|
||||
enabled: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface ServerDiskMount {
|
||||
filesystem: string;
|
||||
type: string;
|
||||
size: string;
|
||||
used: string;
|
||||
available: string;
|
||||
usedPercent: string;
|
||||
mountPoint: string;
|
||||
}
|
||||
|
||||
export interface ServerConfigSnapshot {
|
||||
hostname: string | null;
|
||||
os: string | null;
|
||||
kernel: string | null;
|
||||
currentUser: string | null;
|
||||
serverUser: string;
|
||||
users: ServerSystemUser[];
|
||||
rootUser: ServerSystemUser | null;
|
||||
nonRootUsers: ServerSystemUser[];
|
||||
hasRootUser: boolean;
|
||||
sudoNonInteractive: boolean;
|
||||
docker: DockerRuntimeStatus;
|
||||
services: ServerServiceStatus[];
|
||||
webServer: ServerWebCapability;
|
||||
diskMounts: ServerDiskMount[];
|
||||
lastBoot: string | null;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export async function getServerConfigSnapshot(
|
||||
server: Server,
|
||||
): Promise<ServerConfigSnapshot> {
|
||||
const usersScript = [
|
||||
"while IFS=: read -r user _ uid gid _ home shell; do",
|
||||
' if [ "$uid" -eq 0 ] || [ "$uid" -ge 1000 ]; then',
|
||||
' groups=$(id -nG "$user" 2>/dev/null || true)',
|
||||
' printf \'%s|%s|%s|%s|%s|%s\\n\' "$user" "$uid" "$gid" "$home" "$shell" "$groups"',
|
||||
" fi",
|
||||
"done < /etc/passwd",
|
||||
].join("\n");
|
||||
|
||||
const platformResult = await Promise.allSettled([
|
||||
detectServerPlatform(server, configCommandTimeout(CONFIG_FAST_TIMEOUT_MS)),
|
||||
]);
|
||||
const platform =
|
||||
platformResult[0]?.status === "fulfilled"
|
||||
? platformResult[0].value
|
||||
: UNKNOWN_SERVER_PLATFORM;
|
||||
|
||||
const [
|
||||
hostnameResult,
|
||||
kernelResult,
|
||||
currentUserResult,
|
||||
usersResult,
|
||||
lastBootResult,
|
||||
servicesResult,
|
||||
diskMountsResult,
|
||||
dockerResult,
|
||||
] = await Promise.allSettled([
|
||||
exec(
|
||||
server,
|
||||
'bash -lc "hostnamectl --static 2>/dev/null || hostname 2>/dev/null"',
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
),
|
||||
exec(
|
||||
server,
|
||||
'bash -lc "uname -r 2>/dev/null"',
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
),
|
||||
exec(
|
||||
server,
|
||||
'bash -lc "whoami 2>/dev/null"',
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
),
|
||||
exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(usersScript)}`,
|
||||
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
),
|
||||
exec(
|
||||
server,
|
||||
"bash -lc \"uptime -s 2>/dev/null || who -b 2>/dev/null | sed 's/.*system boot[[:space:]]*//'\"",
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
),
|
||||
listServerServices(server),
|
||||
listDiskMounts(server),
|
||||
getDockerRuntimeStatus(server, {
|
||||
...configCommandTimeout(CONFIG_DOCKER_TIMEOUT_MS),
|
||||
platform,
|
||||
}),
|
||||
]);
|
||||
|
||||
const services =
|
||||
servicesResult.status === "fulfilled" ? servicesResult.value : [];
|
||||
const diskMounts =
|
||||
diskMountsResult.status === "fulfilled" ? diskMountsResult.value : [];
|
||||
const docker =
|
||||
dockerResult.status === "fulfilled"
|
||||
? dockerResult.value
|
||||
: createDockerProbeFailureStatus(
|
||||
dockerResult.reason instanceof Error
|
||||
? dockerResult.reason.message
|
||||
: "Docker status probe failed while loading server config",
|
||||
platform,
|
||||
);
|
||||
|
||||
const webServerResult = await Promise.allSettled([
|
||||
inspectWebServerCapability(
|
||||
server,
|
||||
platform,
|
||||
services,
|
||||
configCommandTimeout(CONFIG_WEB_STACK_TIMEOUT_MS),
|
||||
),
|
||||
]);
|
||||
const webServer =
|
||||
webServerResult[0]?.status === "fulfilled"
|
||||
? webServerResult[0].value
|
||||
: createUnavailableWebCapability(platform, webServerResult[0]?.reason);
|
||||
|
||||
const usersOutput =
|
||||
usersResult.status === "fulfilled" ? usersResult.value.stdout : "";
|
||||
const users = usersOutput
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [username, uidRaw, gidRaw, homeRaw, shellRaw, groupsRaw] =
|
||||
line.split("|");
|
||||
const uid = Number(uidRaw);
|
||||
const gid = Number(gidRaw);
|
||||
|
||||
return {
|
||||
username,
|
||||
uid: Number.isFinite(uid) ? uid : null,
|
||||
gid: Number.isFinite(gid) ? gid : null,
|
||||
home: homeRaw || null,
|
||||
shell: shellRaw || null,
|
||||
groups: (groupsRaw || "")
|
||||
.split(/\s+/)
|
||||
.map((group) => group.trim())
|
||||
.filter(Boolean),
|
||||
isRoot: uid === 0 || username === "root",
|
||||
isSshUser: username === server.username,
|
||||
} satisfies ServerSystemUser;
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.isRoot && !right.isRoot) return -1;
|
||||
if (!left.isRoot && right.isRoot) return 1;
|
||||
return left.username.localeCompare(right.username);
|
||||
});
|
||||
|
||||
const rootUser = users.find((user) => user.isRoot) ?? null;
|
||||
const nonRootUsers = users.filter((user) => !user.isRoot);
|
||||
|
||||
return {
|
||||
hostname:
|
||||
hostnameResult.status === "fulfilled"
|
||||
? hostnameResult.value.stdout.trim() || null
|
||||
: null,
|
||||
os: server.os || docker.platform.distro || null,
|
||||
kernel:
|
||||
kernelResult.status === "fulfilled"
|
||||
? kernelResult.value.stdout.trim() || null
|
||||
: null,
|
||||
currentUser:
|
||||
currentUserResult.status === "fulfilled"
|
||||
? currentUserResult.value.stdout.trim() || null
|
||||
: null,
|
||||
serverUser: server.username,
|
||||
users,
|
||||
rootUser,
|
||||
nonRootUsers,
|
||||
hasRootUser: Boolean(rootUser),
|
||||
sudoNonInteractive: docker.platform.sudoNonInteractive,
|
||||
docker,
|
||||
services,
|
||||
webServer,
|
||||
diskMounts,
|
||||
lastBoot:
|
||||
lastBootResult.status === "fulfilled"
|
||||
? lastBootResult.value.stdout.trim() || null
|
||||
: null,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function createUnavailableWebCapability(
|
||||
platform: ServerPlatformInfo,
|
||||
reason: unknown,
|
||||
): ServerWebCapability {
|
||||
const message =
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Web stack probe failed while loading server config";
|
||||
|
||||
return {
|
||||
ready: false,
|
||||
summary: "Web stack status unavailable",
|
||||
notes: [message],
|
||||
packageManager: platform.packageManager,
|
||||
canManage: false,
|
||||
primaryWebServer: null,
|
||||
support: {
|
||||
staticSites: false,
|
||||
phpApps: false,
|
||||
javascriptApps: false,
|
||||
sslAutomation: false,
|
||||
processManager: false,
|
||||
relationalDatabase: false,
|
||||
cache: false,
|
||||
},
|
||||
components: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function listServerServices(
|
||||
server: Server,
|
||||
): Promise<ServerServiceStatus[]> {
|
||||
const serviceCandidates = getInspectableHostServices();
|
||||
const script = [
|
||||
"command -v systemctl >/dev/null 2>&1 || exit 0",
|
||||
`for svc in ${serviceCandidates.join(" ")}; do`,
|
||||
' load_state=$(systemctl show "$svc" --property=LoadState --value 2>/dev/null || true)',
|
||||
' if [ -n "$load_state" ] && [ "$load_state" != "not-found" ]; then',
|
||||
' active=$(systemctl is-active "$svc" 2>/dev/null || echo inactive)',
|
||||
' enabled=$(systemctl is-enabled "$svc" 2>/dev/null || echo unknown)',
|
||||
' description=$(systemctl show "$svc" --property=Description --value 2>/dev/null || true)',
|
||||
' printf "%s|%s|%s|%s\\n" "$svc" "$active" "$enabled" "$description"',
|
||||
" fi",
|
||||
"done",
|
||||
].join("\n");
|
||||
|
||||
const result = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(script)}`,
|
||||
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
);
|
||||
if (!result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [name, active, enabled, description] = line.split("|");
|
||||
return {
|
||||
name,
|
||||
active: active || "inactive",
|
||||
enabled: enabled || "unknown",
|
||||
description: description || null,
|
||||
} satisfies ServerServiceStatus;
|
||||
});
|
||||
}
|
||||
|
||||
export async function listDiskMounts(
|
||||
server: Server,
|
||||
): Promise<ServerDiskMount[]> {
|
||||
const primaryCommand = [
|
||||
"if command -v findmnt >/dev/null 2>&1; then",
|
||||
" findmnt -J -b -o SOURCE,FSTYPE,SIZE,USED,AVAIL,USE%,TARGET 2>/dev/null",
|
||||
"fi",
|
||||
].join("\n");
|
||||
const primaryResult = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(primaryCommand)}`,
|
||||
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
if (primaryResult.stdout.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(primaryResult.stdout) as {
|
||||
filesystems?: Array<{
|
||||
source?: string;
|
||||
fstype?: string;
|
||||
size?: string | number;
|
||||
used?: string | number;
|
||||
avail?: string | number;
|
||||
"use%"?: string;
|
||||
target?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const mounts = (parsed.filesystems ?? [])
|
||||
.filter(
|
||||
(filesystem) =>
|
||||
filesystem.target &&
|
||||
!["tmpfs", "devtmpfs"].includes(filesystem.fstype ?? ""),
|
||||
)
|
||||
.map((filesystem) => ({
|
||||
filesystem: filesystem.source || "—",
|
||||
type: filesystem.fstype || "—",
|
||||
size: String(filesystem.size ?? "—"),
|
||||
used: String(filesystem.used ?? "—"),
|
||||
available: String(filesystem.avail ?? "—"),
|
||||
usedPercent: filesystem["use%"] || "—",
|
||||
mountPoint: filesystem.target || "—",
|
||||
}))
|
||||
.filter((mount) => mount.mountPoint !== "—");
|
||||
|
||||
if (mounts.length > 0) {
|
||||
return mounts;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to df parsing below when findmnt JSON is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackCommand =
|
||||
'df -hPT -x tmpfs -x devtmpfs 2>/dev/null | awk \'NR>1 {print $1 "|" $2 "|" $3 "|" $4 "|" $5 "|" $6 "|" substr($0, index($0,$7))}\'';
|
||||
const fallbackResult = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(fallbackCommand)}`,
|
||||
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
if (!fallbackResult.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fallbackResult.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [filesystem, type, size, used, available, usedPercent, mountPoint] =
|
||||
line.split("|");
|
||||
return {
|
||||
filesystem: filesystem || "—",
|
||||
type: type || "—",
|
||||
size: size || "—",
|
||||
used: used || "—",
|
||||
available: available || "—",
|
||||
usedPercent: usedPercent || "—",
|
||||
mountPoint: mountPoint || "—",
|
||||
} satisfies ServerDiskMount;
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetServer(server: Server): Promise<void> {
|
||||
const rebootScript =
|
||||
"( sleep 2; (shutdown -r now || systemctl reboot || reboot) ) >/dev/null 2>&1 &";
|
||||
const result = await exec(
|
||||
server,
|
||||
privilegedCommand(server, `bash -lc ${escapeShellArg(rebootScript)}`),
|
||||
);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || result.stdout || "Failed to reset server");
|
||||
}
|
||||
|
||||
closeConnection(server.id);
|
||||
}
|
||||
|
||||
export async function restartNginx(server: Server): Promise<void> {
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
'bash -lc "systemctl restart nginx || systemctl restart apache2 || systemctl restart httpd"',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function reloadNginx(server: Server): Promise<void> {
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
'bash -lc "nginx -t && (systemctl reload nginx || nginx -s reload || systemctl restart nginx || systemctl restart apache2 || systemctl restart httpd)"',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function restartManagedService(
|
||||
server: Server,
|
||||
serviceName: string,
|
||||
): Promise<void> {
|
||||
const normalized = serviceName.trim().toLowerCase();
|
||||
const allowedServices = new Set([
|
||||
"docker",
|
||||
"fail2ban",
|
||||
"caddy",
|
||||
"nginx",
|
||||
"apache2",
|
||||
"httpd",
|
||||
"ssh",
|
||||
"sshd",
|
||||
"ufw",
|
||||
]);
|
||||
|
||||
if (!allowedServices.has(normalized)) {
|
||||
throw new Error(`Service ${serviceName} is not allowed for restart`);
|
||||
}
|
||||
|
||||
if (
|
||||
normalized === "nginx" ||
|
||||
normalized === "apache2" ||
|
||||
normalized === "httpd"
|
||||
) {
|
||||
await restartNginx(server);
|
||||
return;
|
||||
}
|
||||
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(`systemctl restart ${normalized}`)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function rebootServer(server: Server): Promise<void> {
|
||||
await resetServer(server);
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
import { Server } from "@prisma/client";
|
||||
import { exec, execStrict } from "./commands";
|
||||
import { escapeShellArg } from "./internal/shell";
|
||||
import { privilegedCommand } from "./internal/privilege";
|
||||
import {
|
||||
buildCertbotEmailFlag,
|
||||
resolveCertbotCommand,
|
||||
} from "./internal/certbot";
|
||||
|
||||
// NOTE: This file is a modularization of ssh.service.ts (domain: ssl).
|
||||
|
||||
export interface SslCertificateResult {
|
||||
issuer: string;
|
||||
certPem: string;
|
||||
keyPem: string;
|
||||
issuedAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
}
|
||||
|
||||
export interface SslRenewStepTimings {
|
||||
resolveCertificateMs: number;
|
||||
certbotRenewMs: number;
|
||||
readCertificateMs: number;
|
||||
fallbackIssueMs?: number;
|
||||
certbotLockWaitMs?: number;
|
||||
certbotAttempts?: number;
|
||||
totalMs: number;
|
||||
}
|
||||
|
||||
export interface SslRenewCertificateResult extends SslCertificateResult {
|
||||
timings: SslRenewStepTimings;
|
||||
strategy: "renew" | "fallback-issue";
|
||||
}
|
||||
|
||||
export interface SslRenewProgress {
|
||||
stage: string;
|
||||
message: string;
|
||||
timings?: Partial<SslRenewStepTimings>;
|
||||
}
|
||||
|
||||
export interface DiscoveredSslCertificate extends SslCertificateResult {
|
||||
certName: string;
|
||||
domainNames: string[];
|
||||
}
|
||||
|
||||
export interface DeletedSslCertificateResult {
|
||||
certName: string;
|
||||
domainNames: string[];
|
||||
deletedFromServer: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedSslCertificate {
|
||||
certName: string;
|
||||
domainNames: string[];
|
||||
}
|
||||
|
||||
function parseCertbotCertificateLineages(
|
||||
output: string,
|
||||
): ResolvedSslCertificate[] {
|
||||
const results: ResolvedSslCertificate[] = [];
|
||||
let currentCertName: string | null = null;
|
||||
let currentDomainNames: string[] = [];
|
||||
|
||||
const flush = () => {
|
||||
if (!currentCertName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const certName = normalizeDomainName(currentCertName) ?? currentCertName;
|
||||
const domainNames = Array.from(
|
||||
new Set(
|
||||
currentDomainNames
|
||||
.map((value) => normalizeDomainName(value))
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
),
|
||||
);
|
||||
|
||||
if (certName && domainNames.length > 0) {
|
||||
results.push({ certName, domainNames });
|
||||
}
|
||||
};
|
||||
|
||||
for (const rawLine of output.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith("Certificate Name:")) {
|
||||
flush();
|
||||
currentCertName = line.slice("Certificate Name:".length).trim();
|
||||
currentDomainNames = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("Domains:")) {
|
||||
currentDomainNames = line
|
||||
.slice("Domains:".length)
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
return results;
|
||||
}
|
||||
|
||||
async function listCertbotCertificateLineages(
|
||||
server: Server,
|
||||
): Promise<ResolvedSslCertificate[]> {
|
||||
const certbotBinary = await resolveCertbotCommand(server);
|
||||
const result = await exec(
|
||||
server,
|
||||
privilegedCommand(server, `${certbotBinary} certificates 2>&1`),
|
||||
);
|
||||
const output = `${result.stdout}\n${result.stderr}`.trim();
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(output || "certbot certificates failed");
|
||||
}
|
||||
|
||||
return parseCertbotCertificateLineages(output);
|
||||
}
|
||||
|
||||
async function detectActiveWebServer(
|
||||
server: Server,
|
||||
): Promise<"nginx" | "apache2" | "httpd" | null> {
|
||||
const result = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
'for svc in nginx apache2 httpd; do if systemctl is-active "$svc" >/dev/null 2>&1; then echo "$svc"; break; fi; done',
|
||||
)}`,
|
||||
);
|
||||
const name = result.stdout.trim();
|
||||
if (name === "nginx") return "nginx";
|
||||
if (name === "apache2" || name === "httpd") return name;
|
||||
return null;
|
||||
}
|
||||
|
||||
function isMissingCertbotWebServerPlugin(output: string): boolean {
|
||||
return /requested (nginx|apache) plugin does not appear to be installed/i.test(
|
||||
output,
|
||||
);
|
||||
}
|
||||
|
||||
async function issueCertificateWithTemporaryWebServerStop(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
serviceName: "nginx" | "apache2" | "httpd",
|
||||
options?: {
|
||||
forceRenewal?: boolean;
|
||||
certName?: string;
|
||||
domainNames?: string[];
|
||||
},
|
||||
): Promise<SslCertificateResult> {
|
||||
const certbotCommand = await resolveCertbotCommand(server);
|
||||
const certName = options?.certName ?? domainName;
|
||||
const domainNames = normalizeIssuedDomainNames(
|
||||
options?.domainNames ?? [domainName],
|
||||
);
|
||||
const standaloneCommand = [
|
||||
`${certbotCommand} certonly --standalone --non-interactive --agree-tos`,
|
||||
buildCertbotEmailFlag(domainName),
|
||||
...domainNames.map(
|
||||
(issuedDomainName) => `-d ${escapeShellArg(issuedDomainName)}`,
|
||||
),
|
||||
`--cert-name ${escapeShellArg(certName)}`,
|
||||
"--expand",
|
||||
"--renew-with-new-domains",
|
||||
options?.forceRenewal ? "--force-renewal" : "--keep-until-expiring",
|
||||
].join(" ");
|
||||
|
||||
const script = [
|
||||
`systemctl stop ${escapeShellArg(serviceName)}`,
|
||||
`trap 'systemctl start ${escapeShellArg(serviceName)} >/dev/null 2>&1 || true' EXIT`,
|
||||
standaloneCommand,
|
||||
"status=$?",
|
||||
"trap - EXIT",
|
||||
`systemctl start ${escapeShellArg(serviceName)}`,
|
||||
"exit $status",
|
||||
].join("\n");
|
||||
|
||||
await execStrict(
|
||||
server,
|
||||
privilegedCommand(server, `bash -lc ${escapeShellArg(script)}`),
|
||||
);
|
||||
|
||||
return readIssuedCertificate(server, certName);
|
||||
}
|
||||
|
||||
function isPort80Conflict(output: string): boolean {
|
||||
return (
|
||||
output.includes("Could not bind TCP port 80") ||
|
||||
output.includes("Problem binding to port 80") ||
|
||||
output.includes("port 80 because it is already in use")
|
||||
);
|
||||
}
|
||||
|
||||
function isCertbotAlreadyRunning(output: string): boolean {
|
||||
return /another instance of certbot is already running/i.test(output);
|
||||
}
|
||||
|
||||
function isUnsupportedNoRandomSleepOption(output: string): boolean {
|
||||
return /(?:unrecognized arguments?|no such option|unknown option).*--no-random-sleep-on-renew/i.test(
|
||||
output,
|
||||
);
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function isAcmeChallengeFailure(output: string): boolean {
|
||||
return /some challenges have failed|unauthorized|invalid response from|dns problem|no valid ip addresses found|nxdomain|servfail/i.test(
|
||||
output,
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeCertbotOutput(output: string): string {
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(-4)
|
||||
.join(" | ");
|
||||
}
|
||||
|
||||
async function describeAcmeChallengeFailure(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
output: string,
|
||||
): Promise<string | null> {
|
||||
if (!isAcmeChallengeFailure(output)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lookupResult = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
`if command -v getent >/dev/null 2>&1; then getent ahostsv4 ${escapeShellArg(domainName)} | awk '{print $1}' | sort -u; elif command -v host >/dev/null 2>&1; then host ${escapeShellArg(domainName)} | awk '/has address/ {print $4}' | sort -u; elif command -v nslookup >/dev/null 2>&1; then nslookup ${escapeShellArg(domainName)} 2>/dev/null | awk '/^Address: / {print $2}' | sort -u; fi`,
|
||||
)}`,
|
||||
).catch(() => null);
|
||||
|
||||
const resolvedIps = `${lookupResult?.stdout ?? ""}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter((value, index, all) => all.indexOf(value) === index);
|
||||
|
||||
if (resolvedIps.length === 0) {
|
||||
return `Let's Encrypt validation failed because ${domainName} does not currently resolve to a public IPv4 address from the server perspective. Ensure the DNS record exists and has propagated before generating SSL.`;
|
||||
}
|
||||
|
||||
if (!resolvedIps.includes(server.ip.trim())) {
|
||||
return `Let's Encrypt validation failed because ${domainName} currently resolves to ${resolvedIps.join(", ")}, not to this server IP ${server.ip}. Update DNS or wait for propagation before generating SSL.`;
|
||||
}
|
||||
|
||||
return `Let's Encrypt validation still failed even though ${domainName} resolves to this server. Ensure port 80 is publicly reachable, firewall/security group allows HTTP, and any CDN/proxy forwards the HTTP-01 challenge to the origin.`;
|
||||
}
|
||||
|
||||
async function issueCertificateWithDetectedPlugin(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
options?: {
|
||||
forceRenewal?: boolean;
|
||||
certName?: string;
|
||||
domainNames?: string[];
|
||||
},
|
||||
): Promise<SslCertificateResult> {
|
||||
const certbotCommand = await resolveCertbotCommand(server);
|
||||
const webServer = await detectActiveWebServer(server);
|
||||
const domainNames = normalizeIssuedDomainNames(
|
||||
options?.domainNames ?? [domainName],
|
||||
);
|
||||
|
||||
if (
|
||||
webServer !== "nginx" &&
|
||||
webServer !== "apache2" &&
|
||||
webServer !== "httpd"
|
||||
) {
|
||||
throw new Error(
|
||||
`Port 80 is already in use and no compatible web server plugin (nginx/apache) ` +
|
||||
`is active on ${server.name}. Please either free port 80 or ensure nginx/apache ` +
|
||||
`is running before issuing the certificate for ${domainName}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const pluginFlag = webServer === "nginx" ? "--nginx" : "--apache";
|
||||
const webServerLabel = webServer === "nginx" ? "nginx" : "apache";
|
||||
const pluginCommand = [
|
||||
`${certbotCommand} certonly ${pluginFlag} --non-interactive --agree-tos`,
|
||||
buildCertbotEmailFlag(domainName),
|
||||
...domainNames.map(
|
||||
(issuedDomainName) => `-d ${escapeShellArg(issuedDomainName)}`,
|
||||
),
|
||||
`--cert-name ${escapeShellArg(options?.certName ?? domainName)}`,
|
||||
"--expand",
|
||||
"--renew-with-new-domains",
|
||||
options?.forceRenewal ? "--force-renewal" : "--keep-until-expiring",
|
||||
].join(" ");
|
||||
|
||||
try {
|
||||
await execStrict(server, privilegedCommand(server, pluginCommand));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (isMissingCertbotWebServerPlugin(message)) {
|
||||
try {
|
||||
return await issueCertificateWithTemporaryWebServerStop(
|
||||
server,
|
||||
domainName,
|
||||
webServer,
|
||||
options,
|
||||
);
|
||||
} catch (standaloneError) {
|
||||
const standaloneMessage =
|
||||
standaloneError instanceof Error
|
||||
? standaloneError.message
|
||||
: String(standaloneError);
|
||||
const challengeDiagnostic = await describeAcmeChallengeFailure(
|
||||
server,
|
||||
domainName,
|
||||
standaloneMessage,
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
challengeDiagnostic
|
||||
? `${challengeDiagnostic} Certbot output: ${summarizeCertbotOutput(standaloneMessage)}`
|
||||
: `Port 80 is in use, the Certbot ${webServerLabel} plugin is not installed, and standalone fallback after temporarily stopping ${webServer} also failed: ${standaloneMessage}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Port 80 is in use, so Certbot switched to the ${webServerLabel} plugin, ` +
|
||||
`but that also failed: ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return readIssuedCertificate(server, options?.certName ?? domainName);
|
||||
}
|
||||
|
||||
function normalizeDomainName(value: string): string | null {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[.;]+$/g, "");
|
||||
|
||||
if (!normalized) return null;
|
||||
if (normalized === "_" || normalized === "localhost") return null;
|
||||
if (normalized.includes("*") || normalized.startsWith("~")) return null;
|
||||
if (!normalized.includes(".")) return null;
|
||||
if (!/^[a-z0-9.-]+$/.test(normalized)) return null;
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeIssuedDomainNames(domainNames: string[]): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
domainNames
|
||||
.map((value) => normalizeDomainName(value))
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function readIssuedCertificate(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
): Promise<SslCertificateResult> {
|
||||
const certPath = `/etc/letsencrypt/live/${domainName}/fullchain.pem`;
|
||||
const keyPath = `/etc/letsencrypt/live/${domainName}/privkey.pem`;
|
||||
|
||||
const [certPem, keyPem, issuerOut, startDateOut, endDateOut] =
|
||||
await Promise.all([
|
||||
execStrict(
|
||||
server,
|
||||
privilegedCommand(server, `cat ${escapeShellArg(certPath)}`),
|
||||
),
|
||||
execStrict(
|
||||
server,
|
||||
privilegedCommand(server, `cat ${escapeShellArg(keyPath)}`),
|
||||
),
|
||||
execStrict(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
`openssl x509 -in ${escapeShellArg(certPath)} -noout -issuer | sed 's/^issuer=//'`,
|
||||
),
|
||||
),
|
||||
execStrict(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
`openssl x509 -in ${escapeShellArg(certPath)} -noout -startdate | cut -d= -f2-`,
|
||||
),
|
||||
),
|
||||
execStrict(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
`openssl x509 -in ${escapeShellArg(certPath)} -noout -enddate | cut -d= -f2-`,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const issuedAt = startDateOut.trim() ? new Date(startDateOut.trim()) : null;
|
||||
const expiresAt = endDateOut.trim() ? new Date(endDateOut.trim()) : null;
|
||||
|
||||
return {
|
||||
issuer: issuerOut.trim() || "Let's Encrypt",
|
||||
certPem,
|
||||
keyPem,
|
||||
issuedAt: issuedAt && !Number.isNaN(issuedAt.getTime()) ? issuedAt : null,
|
||||
expiresAt:
|
||||
expiresAt && !Number.isNaN(expiresAt.getTime()) ? expiresAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listSslCertificates(
|
||||
server: Server,
|
||||
): Promise<DiscoveredSslCertificate[]> {
|
||||
const certDirs = await exec(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(
|
||||
'for dir in /etc/letsencrypt/live/*; do if [ -d "$dir" ]; then basename "$dir"; fi; done 2>/dev/null',
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
|
||||
const certNames = certDirs.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter((name) => name !== "README");
|
||||
|
||||
if (certNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: DiscoveredSslCertificate[] = [];
|
||||
|
||||
for (const certName of certNames) {
|
||||
const certPath = `/etc/letsencrypt/live/${certName}/fullchain.pem`;
|
||||
const sanOutput = await exec(
|
||||
server,
|
||||
privilegedCommand(
|
||||
server,
|
||||
`openssl x509 -in ${escapeShellArg(certPath)} -noout -ext subjectAltName 2>/dev/null`,
|
||||
),
|
||||
);
|
||||
|
||||
const domainNames = Array.from(
|
||||
new Set(
|
||||
[
|
||||
certName,
|
||||
...Array.from(sanOutput.stdout.matchAll(/DNS:([^,\s]+)/g)).map(
|
||||
(match) => match[1] || "",
|
||||
),
|
||||
]
|
||||
.map((value) => normalizeDomainName(value))
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
),
|
||||
);
|
||||
|
||||
if (domainNames.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const details = await readIssuedCertificate(server, certName);
|
||||
results.push({
|
||||
certName,
|
||||
domainNames,
|
||||
...details,
|
||||
});
|
||||
}
|
||||
|
||||
return results.sort((left, right) =>
|
||||
left.certName.localeCompare(right.certName),
|
||||
);
|
||||
}
|
||||
|
||||
async function findSslCertificateForDomain(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
): Promise<ResolvedSslCertificate | null> {
|
||||
const normalized = normalizeDomainName(domainName);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let certificates: ResolvedSslCertificate[] = [];
|
||||
|
||||
try {
|
||||
certificates = await listCertbotCertificateLineages(server);
|
||||
} catch {
|
||||
const discoveredCertificates = await listSslCertificates(server);
|
||||
certificates = discoveredCertificates.map(({ certName, domainNames }) => ({
|
||||
certName,
|
||||
domainNames,
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
certificates.find((certificate) =>
|
||||
certificate.domainNames.some((name) => name === normalized),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveSslCertificate(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
): Promise<ResolvedSslCertificate | null> {
|
||||
const certificate = await findSslCertificateForDomain(server, domainName);
|
||||
if (!certificate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
certName: certificate.certName,
|
||||
domainNames: certificate.domainNames,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteSslCertificate(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
): Promise<DeletedSslCertificateResult> {
|
||||
const certbotCommand = await resolveCertbotCommand(server);
|
||||
|
||||
const certificate = await resolveSslCertificate(server, domainName);
|
||||
if (!certificate) {
|
||||
return {
|
||||
certName: domainName,
|
||||
domainNames: [domainName],
|
||||
deletedFromServer: false,
|
||||
};
|
||||
}
|
||||
|
||||
const deleteCommand = [
|
||||
`${certbotCommand} delete --non-interactive`,
|
||||
`--cert-name ${escapeShellArg(certificate.certName)}`,
|
||||
].join(" ");
|
||||
|
||||
const deleteResult = await exec(
|
||||
server,
|
||||
privilegedCommand(server, `${deleteCommand} 2>&1`),
|
||||
);
|
||||
const deleteOutput = (deleteResult.stdout + deleteResult.stderr).trim();
|
||||
|
||||
if (deleteResult.code !== 0) {
|
||||
throw new Error(
|
||||
deleteOutput ||
|
||||
`certbot delete failed for certificate lineage ${certificate.certName}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
certName: certificate.certName,
|
||||
domainNames: certificate.domainNames,
|
||||
deletedFromServer: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function issueSslCertificate(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
options?: {
|
||||
domainNames?: string[];
|
||||
certName?: string;
|
||||
},
|
||||
): Promise<SslCertificateResult> {
|
||||
const issuedDomainNames = normalizeIssuedDomainNames(
|
||||
options?.domainNames ?? [domainName],
|
||||
);
|
||||
const primaryDomainName = normalizeDomainName(domainName);
|
||||
const certName = normalizeDomainName(options?.certName ?? domainName);
|
||||
|
||||
if (!primaryDomainName) {
|
||||
throw new Error(`Invalid domain name: ${domainName}`);
|
||||
}
|
||||
|
||||
if (issuedDomainNames.length === 0) {
|
||||
throw new Error("No valid exact domains were provided for SSL issuance");
|
||||
}
|
||||
|
||||
if (!issuedDomainNames.includes(primaryDomainName)) {
|
||||
issuedDomainNames.unshift(primaryDomainName);
|
||||
}
|
||||
|
||||
const certbotBinary = await resolveCertbotCommand(server);
|
||||
|
||||
const certbotCommand = [
|
||||
`${certbotBinary} certonly --standalone --non-interactive --agree-tos`,
|
||||
buildCertbotEmailFlag(primaryDomainName),
|
||||
...issuedDomainNames.map(
|
||||
(issuedDomainName) => `-d ${escapeShellArg(issuedDomainName)}`,
|
||||
),
|
||||
`--cert-name ${escapeShellArg(certName ?? primaryDomainName)}`,
|
||||
"--expand",
|
||||
"--renew-with-new-domains",
|
||||
"--keep-until-expiring",
|
||||
].join(" ");
|
||||
|
||||
const issueResult = await exec(
|
||||
server,
|
||||
privilegedCommand(server, `${certbotCommand} 2>&1`),
|
||||
);
|
||||
const issueOutput = (issueResult.stdout + issueResult.stderr).trim();
|
||||
|
||||
if (issueResult.code === 0) {
|
||||
return readIssuedCertificate(server, certName ?? primaryDomainName);
|
||||
}
|
||||
|
||||
if (isPort80Conflict(issueOutput)) {
|
||||
return issueCertificateWithDetectedPlugin(server, primaryDomainName, {
|
||||
certName: certName ?? primaryDomainName,
|
||||
domainNames: issuedDomainNames,
|
||||
});
|
||||
}
|
||||
|
||||
const challengeDiagnostic = await describeAcmeChallengeFailure(
|
||||
server,
|
||||
primaryDomainName,
|
||||
issueOutput,
|
||||
);
|
||||
|
||||
if (challengeDiagnostic) {
|
||||
throw new Error(
|
||||
`${challengeDiagnostic} Certbot output: ${summarizeCertbotOutput(issueOutput)}`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
issueOutput || `certbot issue failed for ${primaryDomainName}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function renewSslCertificate(
|
||||
server: Server,
|
||||
domainName: string,
|
||||
options?: { onProgress?: (progress: SslRenewProgress) => void },
|
||||
): Promise<SslRenewCertificateResult> {
|
||||
const certbotLockRetryIntervalMs = 5000;
|
||||
const certbotLockRetryTimeoutMs = 3 * 60 * 1000;
|
||||
const totalStartedAt = Date.now();
|
||||
const certbotBinary = await resolveCertbotCommand(server);
|
||||
const resolveStartedAt = Date.now();
|
||||
const certificate = await resolveSslCertificate(server, domainName);
|
||||
const resolveCertificateMs = Date.now() - resolveStartedAt;
|
||||
|
||||
if (!certificate) {
|
||||
throw new Error(
|
||||
`certificate not found on server ${server.name} for domain ${domainName}`,
|
||||
);
|
||||
}
|
||||
|
||||
options?.onProgress?.({
|
||||
stage: "CERTBOT_RENEW",
|
||||
message: `Resolved certificate lineage ${certificate.certName}; starting Certbot renewal`,
|
||||
timings: { resolveCertificateMs },
|
||||
});
|
||||
|
||||
// Step 1: Use `certbot renew --cert-name` which reads the saved renewal config
|
||||
// (respects the original authenticator - webroot, nginx, apache, etc.)
|
||||
// Disable Certbot's random sleep because this is a user-triggered action.
|
||||
const buildRenewCommand = (includeNoRandomSleep: boolean) =>
|
||||
privilegedCommand(
|
||||
server,
|
||||
[
|
||||
`${certbotBinary} renew`,
|
||||
`--cert-name ${escapeShellArg(certificate.certName)}`,
|
||||
"--force-renewal",
|
||||
"--non-interactive",
|
||||
includeNoRandomSleep ? "--no-random-sleep-on-renew" : "",
|
||||
"2>&1",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
);
|
||||
let renewCommand = buildRenewCommand(true);
|
||||
let retriedWithoutNoRandomSleep = false;
|
||||
let renewResult;
|
||||
let renewOutput = "";
|
||||
let certbotRenewMs = 0;
|
||||
let certbotLockWaitMs = 0;
|
||||
let certbotAttempts = 0;
|
||||
|
||||
options?.onProgress?.({
|
||||
stage: "CERTBOT_RENEW",
|
||||
message: `Running Certbot renewal for ${certificate.certName}`,
|
||||
timings: { resolveCertificateMs },
|
||||
});
|
||||
|
||||
/*
|
||||
* Older Certbot builds may not understand --no-random-sleep-on-renew.
|
||||
* Retry without it instead of failing the renewal on legacy hosts.
|
||||
*/
|
||||
const retryWithoutNoRandomSleep = () => {
|
||||
retriedWithoutNoRandomSleep = true;
|
||||
renewCommand = buildRenewCommand(false);
|
||||
options?.onProgress?.({
|
||||
stage: "CERTBOT_RENEW",
|
||||
message:
|
||||
"Certbot on this server does not support no-random-sleep; retrying with legacy flags",
|
||||
timings: {
|
||||
resolveCertificateMs,
|
||||
certbotRenewMs,
|
||||
certbotLockWaitMs,
|
||||
certbotAttempts,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
while (true) {
|
||||
certbotAttempts += 1;
|
||||
const certbotAttemptStartedAt = Date.now();
|
||||
renewResult = await exec(server, renewCommand);
|
||||
certbotRenewMs += Date.now() - certbotAttemptStartedAt;
|
||||
renewOutput = (renewResult.stdout + renewResult.stderr).trim();
|
||||
|
||||
if (
|
||||
!retriedWithoutNoRandomSleep &&
|
||||
isUnsupportedNoRandomSleepOption(renewOutput)
|
||||
) {
|
||||
retryWithoutNoRandomSleep();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isCertbotAlreadyRunning(renewOutput)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (certbotLockWaitMs >= certbotLockRetryTimeoutMs) {
|
||||
throw new Error(
|
||||
`Another Certbot task is still running on ${server.name} after waiting ${Math.round(certbotLockWaitMs / 1000)}s. This usually means a cron/systemd/manual Certbot renewal is already in progress. Retry after that task finishes. Last output: ${renewOutput}`,
|
||||
);
|
||||
}
|
||||
|
||||
options?.onProgress?.({
|
||||
stage: "CERTBOT_LOCK_WAIT",
|
||||
message: `Another Certbot task is running on ${server.name}; waiting ${certbotLockRetryIntervalMs / 1000}s before retry ${certbotAttempts + 1}`,
|
||||
timings: {
|
||||
resolveCertificateMs,
|
||||
certbotRenewMs,
|
||||
certbotLockWaitMs: certbotLockWaitMs + certbotLockRetryIntervalMs,
|
||||
certbotAttempts,
|
||||
},
|
||||
});
|
||||
|
||||
await wait(certbotLockRetryIntervalMs);
|
||||
certbotLockWaitMs += certbotLockRetryIntervalMs;
|
||||
}
|
||||
|
||||
const hasPort80Conflict = isPort80Conflict(renewOutput);
|
||||
|
||||
if (!hasPort80Conflict && renewResult.code === 0) {
|
||||
const readStartedAt = Date.now();
|
||||
const renewedCertificate = await readIssuedCertificate(
|
||||
server,
|
||||
certificate.certName,
|
||||
);
|
||||
const readCertificateMs = Date.now() - readStartedAt;
|
||||
|
||||
return {
|
||||
...renewedCertificate,
|
||||
strategy: "renew",
|
||||
timings: {
|
||||
resolveCertificateMs,
|
||||
certbotRenewMs,
|
||||
readCertificateMs,
|
||||
certbotLockWaitMs,
|
||||
certbotAttempts,
|
||||
totalMs: Date.now() - totalStartedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasPort80Conflict) {
|
||||
// Some other certbot error — surface it directly
|
||||
throw new Error(renewOutput || `certbot renew failed for ${domainName}`);
|
||||
}
|
||||
|
||||
const fallbackStartedAt = Date.now();
|
||||
options?.onProgress?.({
|
||||
stage: "FALLBACK_ISSUE",
|
||||
message: `Port 80 is busy; switching to detected web server plugin for ${domainName}`,
|
||||
timings: {
|
||||
resolveCertificateMs,
|
||||
certbotRenewMs,
|
||||
certbotLockWaitMs,
|
||||
certbotAttempts,
|
||||
},
|
||||
});
|
||||
const renewedCertificate = await issueCertificateWithDetectedPlugin(
|
||||
server,
|
||||
domainName,
|
||||
{
|
||||
certName: certificate.certName,
|
||||
forceRenewal: true,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...renewedCertificate,
|
||||
strategy: "fallback-issue",
|
||||
timings: {
|
||||
resolveCertificateMs,
|
||||
certbotRenewMs,
|
||||
readCertificateMs: 0,
|
||||
fallbackIssueMs: Date.now() - fallbackStartedAt,
|
||||
certbotLockWaitMs,
|
||||
certbotAttempts,
|
||||
totalMs: Date.now() - totalStartedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
// Backward-compatibility barrel.
|
||||
//
|
||||
// Implementasi publik SSH sudah dipindahkan per domain ke folder
|
||||
// `./ssh-services/*`. File monolitik lama disimpan di `./backup/ssh.service.ts`
|
||||
// sebagai referensi migrasi. Semua export publik tetap dipertahankan lewat
|
||||
// re-export di bawah ini.
|
||||
|
||||
export * from "./ssh-services";
|
||||
@@ -0,0 +1,80 @@
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
const TERMINAL_SOCKET_TICKET_TTL_MS = 60 * 1000;
|
||||
|
||||
export interface TerminalSocketTicket {
|
||||
id: string;
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
serverId: string;
|
||||
sessionId?: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const terminalSocketTickets = new Map<string, TerminalSocketTicket>();
|
||||
|
||||
function pruneExpiredTerminalSocketTickets(now = Date.now()) {
|
||||
for (const [ticketId, ticket] of terminalSocketTickets.entries()) {
|
||||
if (ticket.expiresAt <= now) {
|
||||
terminalSocketTickets.delete(ticketId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function issueTerminalSocketTicket(input: {
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
serverId: string;
|
||||
sessionId?: string;
|
||||
}): TerminalSocketTicket {
|
||||
const now = Date.now();
|
||||
pruneExpiredTerminalSocketTickets(now);
|
||||
|
||||
const ticket: TerminalSocketTicket = {
|
||||
id: randomUUID(),
|
||||
userId: input.userId,
|
||||
organizationId: input.organizationId,
|
||||
serverId: input.serverId,
|
||||
sessionId: input.sessionId,
|
||||
createdAt: now,
|
||||
expiresAt: now + TERMINAL_SOCKET_TICKET_TTL_MS,
|
||||
};
|
||||
|
||||
terminalSocketTickets.set(ticket.id, ticket);
|
||||
return ticket;
|
||||
}
|
||||
|
||||
export function consumeTerminalSocketTicket(input: {
|
||||
ticketId?: string;
|
||||
serverId: string;
|
||||
sessionId?: string;
|
||||
}): TerminalSocketTicket | null {
|
||||
const now = Date.now();
|
||||
pruneExpiredTerminalSocketTickets(now);
|
||||
|
||||
if (!input.ticketId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ticket = terminalSocketTickets.get(input.ticketId);
|
||||
terminalSocketTickets.delete(input.ticketId);
|
||||
|
||||
if (!ticket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ticket.expiresAt <= now) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ticket.serverId !== input.serverId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((ticket.sessionId ?? undefined) !== (input.sessionId ?? undefined)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ticket;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { generateSecret, generateURI, verify } from "otplib";
|
||||
import QRCode from "qrcode";
|
||||
import { decrypt, encrypt } from "../lib/crypto";
|
||||
|
||||
export async function createTwoFactorSetup(input: {
|
||||
accountEmail: string;
|
||||
issuer: string;
|
||||
}) {
|
||||
const secret = generateSecret();
|
||||
const otpauthUrl = generateURI({
|
||||
issuer: input.issuer,
|
||||
label: input.accountEmail,
|
||||
secret,
|
||||
});
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl, {
|
||||
margin: 1,
|
||||
width: 220,
|
||||
});
|
||||
|
||||
return {
|
||||
secret,
|
||||
encryptedSecret: encrypt(secret),
|
||||
otpauthUrl,
|
||||
qrCodeDataUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyTwoFactorToken(secret: string, token: string) {
|
||||
const result = await verify({
|
||||
token: token.replace(/\s+/g, ""),
|
||||
secret,
|
||||
});
|
||||
|
||||
return result.valid;
|
||||
}
|
||||
|
||||
export async function verifyEncryptedTwoFactorToken(
|
||||
encryptedSecret: string,
|
||||
token: string,
|
||||
) {
|
||||
return verifyTwoFactorToken(decrypt(encryptedSecret), token);
|
||||
}
|
||||
|
||||
export function revealManualEntryKey(encryptedSecret: string) {
|
||||
return decrypt(encryptedSecret);
|
||||
}
|
||||
Reference in new issue
Block a user