🐛 fix: harden backend authorization and sync

This commit is contained in:
2026-08-13 03:29:17 +07:00
parent 4d29156614
commit ab662a4fa4
35 changed files with 442 additions and 176 deletions
+2 -6
View File
@@ -16,11 +16,7 @@ const k8sService = new K8sService();
const operatorResourceSync = new OperatorResourceSync();
export const userService = new UserService(userRepo);
export const serverService = new ServerService(serverRepo, k8sService, operatorResourceSync);
export const reverseProxyService = new ReverseProxyService(
reverseProxyRepo,
k8sService,
operatorResourceSync
);
export const serverService = new ServerService(serverRepo, k8sService);
export const reverseProxyService = new ReverseProxyService(reverseProxyRepo, k8sService);
export const wsService = webSocketService;
export { k8sService, operatorResourceSync };
@@ -3,7 +3,11 @@ import type { UpdateSuspensionInput, UpdateUserInput, User } from "@minikura/db"
export interface IUserService {
getUserById(id: string): Promise<User>;
getAllUsers(): Promise<User[]>;
updateUser(id: string, input: UpdateUserInput): Promise<User>;
updateSuspension(id: string, input: UpdateSuspensionInput): Promise<User>;
updateUser(requestingUserId: string, id: string, input: UpdateUserInput): Promise<User>;
updateSuspension(
requestingUserId: string,
id: string,
input: UpdateSuspensionInput
): Promise<User>;
deleteUser(requestingUserId: string, targetUserId: string): Promise<void>;
}
@@ -9,11 +9,9 @@ import type {
ReverseProxyRepository,
ReverseProxyUpdateInput,
} from "../../domain/repositories/reverse-proxy.repository";
import { eventBus } from "../../infrastructure/event-bus";
import type { K8sService } from "../../services/k8s";
import {
type OperatorResourceSync,
operatorResourceName,
} from "../../services/operator-resource-sync";
import { operatorResourceName } from "../../services/operator-resource-sync";
import type { IReverseProxyService } from "../interfaces/reverse-proxy.service.interface";
import { BaseCrudService } from "./base-crud.service";
@@ -33,8 +31,7 @@ export class ReverseProxyService
{
constructor(
reverseProxyRepo: ReverseProxyRepository,
private k8sService: K8sService,
private operatorResourceSync: OperatorResourceSync
private k8sService: K8sService
) {
super(
reverseProxyRepo,
@@ -77,12 +74,12 @@ export class ReverseProxyService
override async setEnvVariable(proxyId: string, key: string, value: string): Promise<void> {
await super.setEnvVariable(proxyId, key, value);
await this.operatorResourceSync.syncReverseProxyById(proxyId);
await eventBus.publish(new ReverseProxyUpdatedEvent(proxyId, {}));
}
override async deleteEnvVariable(proxyId: string, key: string): Promise<void> {
await super.deleteEnvVariable(proxyId, key);
await this.operatorResourceSync.syncReverseProxyById(proxyId);
await eventBus.publish(new ReverseProxyUpdatedEvent(proxyId, {}));
}
async getConnectionInfo(proxyId: string) {
@@ -9,11 +9,9 @@ import type {
ServerRepository,
ServerUpdateInput,
} from "../../domain/repositories/server.repository";
import { eventBus } from "../../infrastructure/event-bus";
import type { K8sService } from "../../services/k8s";
import {
type OperatorResourceSync,
operatorResourceName,
} from "../../services/operator-resource-sync";
import { operatorResourceName } from "../../services/operator-resource-sync";
import type { IServerService } from "../interfaces/server.service.interface";
import { BaseCrudService } from "./base-crud.service";
@@ -33,8 +31,7 @@ export class ServerService
{
constructor(
serverRepo: ServerRepository,
private k8sService: K8sService,
private operatorResourceSync: OperatorResourceSync
private k8sService: K8sService
) {
super(
serverRepo,
@@ -77,12 +74,12 @@ export class ServerService
override async setEnvVariable(serverId: string, key: string, value: string): Promise<void> {
await super.setEnvVariable(serverId, key, value);
await this.operatorResourceSync.syncServerById(serverId);
await eventBus.publish(new ServerUpdatedEvent(serverId, {}));
}
override async deleteEnvVariable(serverId: string, key: string): Promise<void> {
await super.deleteEnvVariable(serverId, key);
await this.operatorResourceSync.syncServerById(serverId);
await eventBus.publish(new ServerUpdatedEvent(serverId, {}));
}
async getConnectionInfo(serverId: string) {
@@ -0,0 +1,50 @@
import { describe, expect, mock, test } from "bun:test";
import type { User } from "@minikura/db";
import type { UserRepository } from "../../domain/repositories/user.repository";
import { UserService } from "./user.service";
const user = { id: "admin", role: "admin" } as User;
function repository(): UserRepository {
return {
findById: mock(async () => user),
findAll: mock(async () => [user]),
updateWithAdminSafety: mock(async () => user),
updateSuspensionWithAdminSafety: mock(async () => user),
deleteWithAdminSafety: mock(async () => undefined),
};
}
describe("UserService lockout protection", () => {
test("rejects self-demotion before writing", async () => {
const repo = repository();
const service = new UserService(repo);
await expect(service.updateUser("admin", "admin", { role: "user" })).rejects.toThrow(
"Cannot demote yourself"
);
expect(repo.updateWithAdminSafety).not.toHaveBeenCalled();
});
test("rejects active self-suspension before writing", async () => {
const repo = repository();
const service = new UserService(repo);
await expect(
service.updateSuspension("admin", "admin", { isSuspended: true, suspendedUntil: null })
).rejects.toThrow("Cannot suspend yourself");
expect(repo.updateSuspensionWithAdminSafety).not.toHaveBeenCalled();
});
test("permits an already-expired suspension", async () => {
const repo = repository();
const service = new UserService(repo);
await expect(
service.updateSuspension("admin", "admin", {
isSuspended: true,
suspendedUntil: new Date(0),
})
).resolves.toBe(user);
});
});
@@ -23,12 +23,24 @@ export class UserService implements IUserService {
return this.userRepo.findAll();
}
async updateUser(id: string, input: UpdateUserInput): Promise<User> {
return this.userRepo.update(id, input);
async updateUser(requestingUserId: string, id: string, input: UpdateUserInput): Promise<User> {
if (requestingUserId === id && input.role && input.role !== "admin") {
throw new BusinessRuleError("Cannot demote yourself");
}
return this.userRepo.updateWithAdminSafety(id, input);
}
async updateSuspension(id: string, input: UpdateSuspensionInput): Promise<User> {
const user = await this.userRepo.updateSuspension(id, input);
async updateSuspension(
requestingUserId: string,
id: string,
input: UpdateSuspensionInput
): Promise<User> {
const suspensionIsActive =
input.isSuspended && (!input.suspendedUntil || input.suspendedUntil > new Date());
if (requestingUserId === id && suspensionIsActive) {
throw new BusinessRuleError("Cannot suspend yourself");
}
const user = await this.userRepo.updateSuspensionWithAdminSafety(id, input);
if (input.isSuspended) {
const suspendedUntil = input.suspendedUntil instanceof Date ? input.suspendedUntil : null;
await eventBus.publish(new UserSuspendedEvent(id, suspendedUntil));
@@ -42,6 +54,6 @@ export class UserService implements IUserService {
if (requestingUserId === targetUserId) {
throw new BusinessRuleError("Cannot delete yourself");
}
await this.userRepo.delete(targetUserId);
await this.userRepo.deleteWithAdminSafety(targetUserId);
}
}
@@ -3,7 +3,7 @@ import type { UpdateSuspensionInput, UpdateUserInput, User } from "@minikura/db"
export interface UserRepository {
findById(id: string): Promise<User | null>;
findAll(): Promise<User[]>;
update(id: string, input: UpdateUserInput): Promise<User>;
updateSuspension(id: string, input: UpdateSuspensionInput): Promise<User>;
delete(id: string): Promise<void>;
updateWithAdminSafety(id: string, input: UpdateUserInput): Promise<User>;
updateSuspensionWithAdminSafety(id: string, input: UpdateSuspensionInput): Promise<User>;
deleteWithAdminSafety(id: string): Promise<void>;
}
+4
View File
@@ -31,6 +31,10 @@ const app = new Elysia({ adapter: node() })
set.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, Cookie";
})
.options("/*", () => new Response(null, { status: 204 }))
.all("/auth/admin/*", ({ set }) => {
set.status = 404;
return { message: "Not found" };
})
.all("/auth/*", ({ request }) => auth.handler(request))
.use(bootstrapRoutes)
.use(authPlugin)
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import { DomainEvent } from "../domain/events/domain-event";
import { EventBus } from "./event-bus";
class TestEvent extends DomainEvent {}
describe("EventBus", () => {
test("continues dispatching after a handler fails", async () => {
const bus = new EventBus();
let handled = false;
bus.subscribe(TestEvent, () => {
throw new Error("sync failed");
});
bus.subscribe(TestEvent, () => {
handled = true;
});
await expect(bus.publish(new TestEvent())).resolves.toBeUndefined();
expect(handled).toBeTrue();
});
});
@@ -19,8 +19,8 @@ export class PrismaReverseProxyRepository implements ReverseProxyRepository {
if (!proxy) return null;
if (omitSensitive) {
const { api_key, ...rest } = proxy;
return { ...rest, api_key: "" } as ReverseProxyWithEnvVars;
const { api_key, env_variables, ...rest } = proxy;
return { ...rest, api_key: "", env_variables: [] } as ReverseProxyWithEnvVars;
}
return proxy;
@@ -33,8 +33,8 @@ export class PrismaReverseProxyRepository implements ReverseProxyRepository {
if (omitSensitive) {
return proxies.map((proxy) => {
const { api_key, ...rest } = proxy;
return { ...rest, api_key: "" } as ReverseProxyWithEnvVars;
const { api_key, env_variables, ...rest } = proxy;
return { ...rest, api_key: "", env_variables: [] } as ReverseProxyWithEnvVars;
});
}
@@ -19,8 +19,8 @@ export class PrismaServerRepository implements ServerRepository {
if (!server) return null;
if (omitSensitive) {
const { api_key, ...rest } = server;
return { ...rest, api_key: "" } as ServerWithEnvVars;
const { api_key, env_variables, ...rest } = server;
return { ...rest, api_key: "", env_variables: [] } as ServerWithEnvVars;
}
return server;
@@ -33,8 +33,8 @@ export class PrismaServerRepository implements ServerRepository {
if (omitSensitive) {
return servers.map((server) => {
const { api_key, ...rest } = server;
return { ...rest, api_key: "" } as ServerWithEnvVars;
const { api_key, env_variables, ...rest } = server;
return { ...rest, api_key: "", env_variables: [] } as ServerWithEnvVars;
});
}
@@ -1,6 +1,17 @@
import { prisma, type UpdateSuspensionInput, type UpdateUserInput, type User } from "@minikura/db";
import { BusinessRuleError, NotFoundError } from "../../../domain/errors/base.error";
import type { UserRepository } from "../../../domain/repositories/user.repository";
const activeAdminFilter = (excludedId: string) => ({
id: { not: excludedId },
role: "admin",
banned: false,
OR: [
{ isSuspended: false },
{ isSuspended: true, suspendedUntil: { not: null, lte: new Date() } },
],
});
export class PrismaUserRepository implements UserRepository {
async findById(id: string): Promise<User | null> {
return await prisma.user.findUnique({
@@ -14,23 +25,58 @@ export class PrismaUserRepository implements UserRepository {
});
}
async update(id: string, input: UpdateUserInput): Promise<User> {
return await prisma.user.update({
where: { id },
data: input,
});
async updateWithAdminSafety(id: string, input: UpdateUserInput): Promise<User> {
return prisma.$transaction(
async (tx) => {
const target = await tx.user.findUnique({ where: { id } });
if (!target) throw new NotFoundError("User", id);
if (
target.role === "admin" &&
input.role === "user" &&
(await tx.user.count({ where: activeAdminFilter(id) })) === 0
) {
throw new BusinessRuleError("Cannot demote the last active administrator");
}
return tx.user.update({ where: { id }, data: input });
},
{ isolationLevel: "Serializable" }
);
}
async updateSuspension(id: string, input: UpdateSuspensionInput): Promise<User> {
return await prisma.user.update({
where: { id },
data: input,
});
async updateSuspensionWithAdminSafety(id: string, input: UpdateSuspensionInput): Promise<User> {
return prisma.$transaction(
async (tx) => {
const target = await tx.user.findUnique({ where: { id } });
if (!target) throw new NotFoundError("User", id);
const suspensionIsActive =
input.isSuspended && (!input.suspendedUntil || input.suspendedUntil > new Date());
if (
target.role === "admin" &&
suspensionIsActive &&
(await tx.user.count({ where: activeAdminFilter(id) })) === 0
) {
throw new BusinessRuleError("Cannot suspend the last active administrator");
}
return tx.user.update({ where: { id }, data: input });
},
{ isolationLevel: "Serializable" }
);
}
async delete(id: string): Promise<void> {
await prisma.user.delete({
where: { id },
});
async deleteWithAdminSafety(id: string): Promise<void> {
await prisma.$transaction(
async (tx) => {
const target = await tx.user.findUnique({ where: { id } });
if (!target) throw new NotFoundError("User", id);
if (
target.role === "admin" &&
(await tx.user.count({ where: activeAdminFilter(id) })) === 0
) {
throw new BusinessRuleError("Cannot delete the last active administrator");
}
await tx.user.delete({ where: { id } });
},
{ isolationLevel: "Serializable" }
);
}
}
@@ -0,0 +1,12 @@
import { describe, expect, test } from "bun:test";
import { assertAdmin } from "./auth-guards";
describe("assertAdmin", () => {
test("allows administrators", () => {
expect(() => assertAdmin({ role: "admin" })).not.toThrow();
});
test("rejects non-administrators", () => {
expect(() => assertAdmin({ role: "user" })).toThrow("admin access required");
});
});
+17 -3
View File
@@ -5,15 +5,21 @@ import { bearerToken, findApiKeyOwner } from "./api-key";
function authenticatedUser(ctx: { user?: User | null; isSuspended?: boolean }) {
const { user, isSuspended } = ctx;
if (!user) {
throw new UnauthorizedError();
}
if (isSuspended) {
throw new ForbiddenError("Account is suspended");
}
if (!user) {
throw new UnauthorizedError();
}
return { user };
}
export function assertAdmin(user: Pick<User, "role">): void {
if (user.role !== "admin") {
throw new ForbiddenError("admin access required");
}
}
export const requireAuth = (app: Elysia) => {
return app.derive((ctx: any) => authenticatedUser(ctx));
};
@@ -39,3 +45,11 @@ export const requirePluginApiKey = (app: Elysia) => {
return { pluginAuth: owner };
});
};
export const requirePluginKind = (kind: "server" | "reverse-proxy") => (app: Elysia) =>
app.use(requirePluginApiKey).derive(({ pluginAuth }) => {
if (pluginAuth.kind !== kind) {
throw new ForbiddenError(`API key is not authorized for ${kind} resources`);
}
return { pluginAuth };
});
+12 -10
View File
@@ -11,19 +11,20 @@ async function getSessionFromHeaders(headers: Headers | Record<string, string>)
});
}
export const authPlugin = new Elysia({ name: "auth" })
.mount(auth.handler)
.derive({ as: "scoped" }, async ({ request }) => {
export const authPlugin = new Elysia({ name: "auth" }).derive(
{ as: "scoped" },
async ({ request }) => {
const session = await getSessionFromHeaders(request.headers);
if (
session?.user &&
isUserSuspended(
session.user as unknown as Pick<
{ isSuspended: boolean; suspendedUntil: Date | null },
"isSuspended" | "suspendedUntil"
>
)
((session.user as unknown as { banned?: boolean }).banned === true ||
isUserSuspended(
session.user as unknown as Pick<
{ isSuspended: boolean; suspendedUntil: Date | null },
"isSuspended" | "suspendedUntil"
>
))
) {
return {
user: null,
@@ -39,6 +40,7 @@ export const authPlugin = new Elysia({ name: "auth" })
isAuthenticated: Boolean(session?.user),
isSuspended: false,
};
});
}
);
export type AuthPlugin = typeof authPlugin;
+7 -1
View File
@@ -10,7 +10,13 @@ export const auth = betterAuth({
provider: "postgresql",
usePlural: false,
}),
emailAndPassword: { enabled: true },
emailAndPassword: { enabled: true, disableSignUp: true },
user: {
additionalFields: {
isSuspended: { type: "boolean", required: false, defaultValue: false, input: false },
suspendedUntil: { type: "date", required: false, input: false },
},
},
plugins: [admin(), openAPI()],
trustedOrigins: [webUrl],
basePath: "/auth",
+30 -25
View File
@@ -1,5 +1,4 @@
import { prisma } from "@minikura/db";
import { getErrorMessage } from "@minikura/shared/errors";
import { Elysia } from "elysia";
import { logger } from "../infrastructure/logger";
import { auth } from "../middleware/auth";
@@ -11,31 +10,37 @@ export const bootstrapRoutes = new Elysia({ prefix: "/bootstrap" })
return { needsSetup: userCount === 0 };
})
.post("/setup", async ({ body, set }) => {
const userCount = await prisma.user.count();
if (userCount > 0) {
set.status = 400;
return { message: "Setup already completed" };
}
const validated = bootstrapSchema.safeParse(body);
if (!validated.success) {
const firstError = validated.error.issues[0];
set.status = 400;
return {
message: `${firstError.path.join(".")}: ${firstError.message}`,
};
}
const data = validated.data;
try {
const result = await auth.api.createUser({
body: {
email: data.email,
password: data.password,
name: data.name,
role: "admin",
const validated = bootstrapSchema.safeParse(body);
if (!validated.success) {
const firstError = validated.error.issues[0];
set.status = 400;
return {
message: `${firstError.path.join(".")}: ${firstError.message}`,
};
}
const data = validated.data;
const result = await prisma.$transaction(
async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(673886947)`;
if ((await tx.user.count()) > 0) return null;
return auth.api.createUser({
body: {
email: data.email,
password: data.password,
name: data.name,
role: "admin",
},
});
},
});
{ timeout: 15_000 }
);
if (!result) {
set.status = 400;
return { message: "Setup already completed" };
}
if (!result.user) {
logger.error({ result }, "No user in bootstrap response");
@@ -47,6 +52,6 @@ export const bootstrapRoutes = new Elysia({ prefix: "/bootstrap" })
} catch (err: unknown) {
logger.error({ err }, "Bootstrap setup failed");
set.status = 500;
return { message: getErrorMessage(err) };
return { message: "Failed to complete setup" };
}
});
+5 -4
View File
@@ -1,10 +1,11 @@
import { labelKeys } from "@minikura/api";
import { Elysia } from "elysia";
import { k8sService } from "../application/di-container";
import { requireAuth } from "../middleware/auth-guards";
import { requireAdmin } from "../middleware/auth-guards";
import { operatorResourceName } from "../services/operator-resource-sync";
export const k8sRoutes = new Elysia({ prefix: "/k8s" })
.use(requireAuth)
.use(requireAdmin)
.get("/status", async () => {
return k8sService.getConnectionInfo();
})
@@ -50,11 +51,11 @@ export const k8sRoutes = new Elysia({ prefix: "/k8s" })
return logs;
})
.get("/servers/:serverId/pods", async ({ params }) => {
const labelSelector = `${labelKeys.serverId}=${params.serverId}`;
const labelSelector = `${labelKeys.serverId}=${operatorResourceName(params.serverId)}`;
return await k8sService.getPodsByLabel(labelSelector);
})
.get("/reverse-proxy/:serverId/pods", async ({ params }) => {
const labelSelector = `${labelKeys.proxyId}=${params.serverId}`;
const labelSelector = `${labelKeys.proxyId}=${operatorResourceName(params.serverId)}`;
return await k8sService.getPodsByLabel(labelSelector);
})
.get("/services/:serviceName", async ({ params }) => {
+13 -4
View File
@@ -1,8 +1,17 @@
import { Elysia } from "elysia";
import { reverseProxyService, serverService } from "../application/di-container";
import { requirePluginApiKey } from "../middleware/auth-guards";
import { requirePluginKind } from "../middleware/auth-guards";
import { operatorResourceName } from "../services/operator-resource-sync";
export const pluginRoutes = new Elysia({ prefix: "/plugin" })
.use(requirePluginApiKey)
.get("/servers", async () => serverService.getAllServers(true))
.get("/reverse-proxy", async () => reverseProxyService.getAllReverseProxies(true));
.use(requirePluginKind("reverse-proxy"))
.get("/servers", async () => {
const namespace = process.env.KUBERNETES_NAMESPACE || "minikura";
return (await serverService.getAllServers(true)).map((server) => ({
...server,
connection_address: `minecraft-${operatorResourceName(server.id)}.${namespace}.svc.cluster.local`,
}));
})
.get("/reverse-proxy", async ({ pluginAuth }) => [
await reverseProxyService.getReverseProxyById(pluginAuth.id, true),
]);
+17 -11
View File
@@ -1,6 +1,6 @@
import { Elysia } from "elysia";
import { reverseProxyService } from "../application/di-container";
import { requireAuth } from "../middleware/auth-guards";
import { assertAdmin, requireAuth } from "../middleware/auth-guards";
import {
createReverseProxySchema,
envVariableSchema,
@@ -9,47 +9,53 @@ import {
export const reverseProxyRoutes = new Elysia({ prefix: "/reverse-proxy" })
.use(requireAuth)
.get("/", async () => {
return await reverseProxyService.getAllReverseProxies(false);
.get("/", async ({ user }) => {
return await reverseProxyService.getAllReverseProxies(user.role !== "admin");
})
.get("/:id", async ({ params }) => {
return await reverseProxyService.getReverseProxyById(params.id, false);
.get("/:id", async ({ params, user }) => {
return await reverseProxyService.getReverseProxyById(params.id, user.role !== "admin");
})
.get("/:id/connection-info", async ({ params }) => {
return await reverseProxyService.getConnectionInfo(params.id);
})
.post("/", async ({ body }) => {
.post("/", async ({ body, user }) => {
assertAdmin(user);
const payload = createReverseProxySchema.parse(body);
const proxy = await reverseProxyService.createReverseProxy(payload);
return proxy;
})
.patch("/:id", async ({ params, body }) => {
.patch("/:id", async ({ params, body, user }) => {
assertAdmin(user);
const payload = updateReverseProxySchema.parse(body);
const proxy = await reverseProxyService.updateReverseProxy(params.id, payload);
return proxy;
})
.delete("/:id", async ({ params }) => {
.delete("/:id", async ({ params, user }) => {
assertAdmin(user);
await reverseProxyService.deleteReverseProxy(params.id);
return { success: true };
})
.get("/:id/env", async ({ params }) => {
.get("/:id/env", async ({ params, user }) => {
assertAdmin(user);
const envVariables = await reverseProxyService.getEnvVariables(params.id);
return { env_variables: envVariables };
})
.post("/:id/env", async ({ params, body }) => {
.post("/:id/env", async ({ params, body, user }) => {
assertAdmin(user);
const payload = envVariableSchema.parse(body);
await reverseProxyService.setEnvVariable(params.id, payload.key, payload.value);
return { success: true };
})
.delete("/:id/env/:key", async ({ params }) => {
.delete("/:id/env/:key", async ({ params, user }) => {
assertAdmin(user);
await reverseProxyService.deleteEnvVariable(params.id, params.key);
return { success: true };
});
+24 -15
View File
@@ -1,7 +1,7 @@
import { Elysia } from "elysia";
import { serverService, wsService } from "../application/di-container";
import { findApiKeyOwner } from "../middleware/api-key";
import { requireAuth } from "../middleware/auth-guards";
import { bearerToken, findApiKeyOwner } from "../middleware/api-key";
import { assertAdmin, requireAuth } from "../middleware/auth-guards";
import {
createServerSchema,
envVariableSchema,
@@ -12,10 +12,13 @@ import type { WebSocketClient } from "../services/websocket";
export const serverRoutes = new Elysia({ prefix: "/servers" })
.ws("/ws", {
async open(
ws: WebSocketClient & { data?: { query?: Record<string, string> }; close: () => void }
ws: WebSocketClient & {
data?: { headers?: Record<string, string | undefined> };
close: () => void;
}
) {
const owner = await findApiKeyOwner(ws.data?.query?.apiKey ?? "");
if (!owner) {
const owner = await findApiKeyOwner(bearerToken(ws.data?.headers?.authorization ?? null));
if (owner?.kind !== "reverse-proxy") {
ws.close();
return;
}
@@ -27,47 +30,53 @@ export const serverRoutes = new Elysia({ prefix: "/servers" })
message() {},
})
.use(requireAuth)
.get("/", async () => {
return await serverService.getAllServers(false);
.get("/", async ({ user }) => {
return await serverService.getAllServers(user.role !== "admin");
})
.get("/:id", async ({ params }) => {
return await serverService.getServerById(params.id, false);
.get("/:id", async ({ params, user }) => {
return await serverService.getServerById(params.id, user.role !== "admin");
})
.get("/:id/connection-info", async ({ params }) => {
return await serverService.getConnectionInfo(params.id);
})
.post("/", async ({ body }) => {
.post("/", async ({ body, user }) => {
assertAdmin(user);
const payload = createServerSchema.parse(body);
const server = await serverService.createServer(payload);
return server;
})
.patch("/:id", async ({ params, body }) => {
.patch("/:id", async ({ params, body, user }) => {
assertAdmin(user);
const payload = updateServerSchema.parse(body);
const server = await serverService.updateServer(params.id, payload);
return server;
})
.delete("/:id", async ({ params }) => {
.delete("/:id", async ({ params, user }) => {
assertAdmin(user);
await serverService.deleteServer(params.id);
return { success: true };
})
.get("/:id/env", async ({ params }) => {
.get("/:id/env", async ({ params, user }) => {
assertAdmin(user);
const envVariables = await serverService.getEnvVariables(params.id);
return { env_variables: envVariables };
})
.post("/:id/env", async ({ params, body }) => {
.post("/:id/env", async ({ params, body, user }) => {
assertAdmin(user);
const payload = envVariableSchema.parse(body);
await serverService.setEnvVariable(params.id, payload.key, payload.value);
return { success: true };
})
.delete("/:id/env/:key", async ({ params }) => {
.delete("/:id/env/:key", async ({ params, user }) => {
assertAdmin(user);
await serverService.deleteEnvVariable(params.id, params.key);
return { success: true };
});
+2 -2
View File
@@ -2,7 +2,7 @@ import { getErrorMessage } from "@minikura/shared/errors";
import { Elysia } from "elysia";
import { k8sService } from "../application/di-container";
import { logger } from "../infrastructure/logger";
import { requireAuth } from "../middleware/auth-guards";
import { requireAdmin } from "../middleware/auth-guards";
type TerminalWsData = {
query?: Record<string, string>;
@@ -26,7 +26,7 @@ type BunTlsOptions = {
ca?: string;
};
export const terminalRoutes = new Elysia({ prefix: "/terminal" }).use(requireAuth).ws("/exec", {
export const terminalRoutes = new Elysia({ prefix: "/terminal" }).use(requireAdmin).ws("/exec", {
open: async (ws: TerminalWs) => {
const podName = ws.data.query?.podName;
const container = ws.data.query?.container;
+4 -4
View File
@@ -11,13 +11,13 @@ export const userRoutes = new Elysia({ prefix: "/users" })
.get("/:id", async ({ params }) => {
return await userService.getUserById(params.id);
})
.patch("/:id", async ({ params, body }) => {
.patch("/:id", async ({ params, body, user }) => {
const input = updateUserSchema.parse(body);
return await userService.updateUser(params.id, input);
return await userService.updateUser(user.id, params.id, input);
})
.patch("/:id/suspension", async ({ params, body }) => {
.patch("/:id/suspension", async ({ params, body, user }) => {
const payload = updateSuspensionSchema.parse(body);
return await userService.updateSuspension(params.id, {
return await userService.updateSuspension(user.id, params.id, {
isSuspended: payload.isSuspended,
suspendedUntil: payload.suspendedUntil ? new Date(payload.suspendedUntil) : null,
});
+9 -14
View File
@@ -7,18 +7,16 @@ import {
import { z } from "zod";
import { GameMode, ServerDifficulty } from "../domain/entities/enums";
export const serverIdSchema = z.object({
id: z
.string()
.min(1, "Server ID is required")
.regex(/^[a-zA-Z0-9-_]+$/, "ID must be alphanumeric with - or _"),
});
const resourceIdSchema = z
.string()
.min(1, "Server ID is required")
.max(51, "Server ID must be at most 51 characters")
.regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, "ID must be a lowercase DNS label");
export const serverIdSchema = z.object({ id: resourceIdSchema });
export const createServerSchema = z.object({
id: z
.string()
.min(1, "Server ID is required")
.regex(/^[a-zA-Z0-9-_]+$/, "ID must be alphanumeric with - or _"),
id: resourceIdSchema,
description: z.string().nullable().optional(),
listen_port: z.number().int().min(1).max(65535),
type: z.nativeEnum(ServerType),
@@ -57,10 +55,7 @@ export const createServerSchema = z.object({
export const updateServerSchema = createServerSchema.omit({ id: true, type: true }).partial();
export const createReverseProxySchema = z.object({
id: z
.string()
.min(1, "Server ID is required")
.regex(/^[a-zA-Z0-9-_]+$/, "ID must be alphanumeric with - or _"),
id: resourceIdSchema,
description: z.string().nullable().optional(),
external_address: z.string().min(1, "External address is required"),
external_port: z.number().int().min(1).max(65535),
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { updateSuspensionSchema } from "./user.schema";
describe("updateSuspensionSchema", () => {
test("accepts an RFC 3339 timestamp with timezone", () => {
expect(
updateSuspensionSchema.parse({
isSuspended: true,
suspendedUntil: "2026-08-13T12:30:00Z",
}).suspendedUntil
).toBe("2026-08-13T12:30:00Z");
});
test.each(["not-a-date", "2026-08-13", "2026-08-13T12:30:00"])(
"rejects invalid or timezone-free timestamp %s",
(suspendedUntil) => {
expect(
updateSuspensionSchema.safeParse({ isSuspended: true, suspendedUntil }).success
).toBeFalse();
}
);
});
+1 -1
View File
@@ -7,7 +7,7 @@ export const updateUserSchema = z.object({
export const updateSuspensionSchema = z.object({
isSuspended: z.boolean(),
suspendedUntil: z.string().nullable().optional(),
suspendedUntil: z.iso.datetime({ offset: true }).nullable().optional(),
});
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
@@ -10,6 +10,7 @@ interface CustomResourceItem {
namespace?: string;
creationTimestamp?: string;
labels?: Record<string, string>;
annotations?: Record<string, string>;
};
spec?: Record<string, unknown>;
status?: { phase?: string; [key: string]: unknown };
@@ -47,6 +48,7 @@ export class CustomResourceOperations extends BaseK8sOperations {
namespace: item.metadata?.namespace ?? this.namespace,
age: getAge(item.metadata?.creationTimestamp),
labels: item.metadata?.labels,
annotations: item.metadata?.annotations,
spec: item.spec ?? {},
status: item.status ?? {},
}));
@@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test";
import { operatorResourceName } from "./operator-resource-sync";
describe("operatorResourceName", () => {
test("preserves short valid lowercase DNS names", () => {
expect(operatorResourceName("survival-1")).toBe("survival-1");
});
test("rejects non-canonical IDs", () => {
expect(() => operatorResourceName("Lobby_A")).toThrow("Invalid Kubernetes resource ID");
expect(() => operatorResourceName("lobby-")).toThrow("Invalid Kubernetes resource ID");
});
test("keeps names short enough for operator-generated prefixes and suffixes", () => {
expect(() => operatorResourceName("a".repeat(52))).toThrow("Invalid Kubernetes resource ID");
});
});
@@ -3,9 +3,11 @@ import { API_GROUP } from "@minikura/api";
import { prisma, type ReverseProxyWithEnvVars, type ServerWithEnvVars } from "@minikura/db";
import { buildKubeConfig } from "@minikura/shared/kube-auth";
import { logger } from "../infrastructure/logger";
const API_VERSION = "v1alpha1";
const FIELD_MANAGER = "minikura-backend";
const SYNC_INTERVAL_MS = 30_000;
const DEFAULT_OPERATOR_BACKEND_URL = "http://minikura-backend:3000/api";
type CustomResource = {
apiVersion: string;
@@ -14,19 +16,17 @@ type CustomResource = {
name: string;
namespace: string;
labels: Record<string, string>;
annotations?: Record<string, string>;
resourceVersion?: string;
};
spec: Record<string, unknown>;
};
export function operatorResourceName(id: string): string {
const normalized = id
.toLowerCase()
.replace(/[^a-z0-9.-]+/g, "-")
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "")
.slice(0, 63);
if (!normalized) throw new Error(`Cannot derive a Kubernetes resource name from ${id}`);
return normalized;
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(id) || id.length > 51) {
throw new Error(`Invalid Kubernetes resource ID: ${id}`);
}
return id;
}
function serviceType(type: string): "ClusterIP" | "NodePort" | "LoadBalancer" {
@@ -38,12 +38,15 @@ function serviceType(type: string): "ClusterIP" | "NodePort" | "LoadBalancer" {
function labels(id: string): Record<string, string> {
return {
"app.kubernetes.io/managed-by": FIELD_MANAGER,
"minikura.kirameki.cafe/database-id": id.slice(0, 63),
"minikura.kirameki.cafe/database-id": operatorResourceName(id),
};
}
export class OperatorResourceSync {
private readonly namespace = process.env.KUBERNETES_NAMESPACE || "minikura";
private readonly backendUrl =
process.env.MINIKURA_OPERATOR_BACKEND_URL || DEFAULT_OPERATOR_BACKEND_URL;
private readonly velocityPluginUrl = process.env.MINIKURA_VELOCITY_PLUGIN_URL;
private coreApi?: k8s.CoreV1Api;
private customObjectsApi?: k8s.CustomObjectsApi;
private syncing = false;
@@ -72,10 +75,19 @@ export class OperatorResourceSync {
prisma.server.findMany({ include: { env_variables: true } }),
prisma.reverseProxyServer.findMany({ include: { env_variables: true } }),
]);
await Promise.all([
const syncResults = await Promise.allSettled([
...servers.map((server) => this.syncServer(server)),
...proxies.map((proxy) => this.syncReverseProxy(proxy)),
]);
const failures = syncResults.filter(
(result): result is PromiseRejectedResult => result.status === "rejected"
);
if (failures.length > 0) {
for (const failure of failures) {
logger.error({ err: failure.reason }, "Failed to synchronize an operator resource");
}
return;
}
await Promise.all([
this.deleteStaleResources(
"minecraftservers",
@@ -94,7 +106,7 @@ export class OperatorResourceSync {
}
async syncServerById(id: string): Promise<void> {
if (!this.coreApi || !this.customObjectsApi) return;
this.requireClients();
const server = await prisma.server.findUnique({
where: { id },
include: { env_variables: true },
@@ -103,7 +115,7 @@ export class OperatorResourceSync {
}
async syncReverseProxyById(id: string): Promise<void> {
if (!this.coreApi || !this.customObjectsApi) return;
this.requireClients();
const proxy = await prisma.reverseProxyServer.findUnique({
where: { id },
include: { env_variables: true },
@@ -112,23 +124,27 @@ export class OperatorResourceSync {
}
async deleteServer(id: string): Promise<void> {
if (!this.customObjectsApi) return;
this.requireClients();
await this.deleteResource("minecraftservers", operatorResourceName(id));
}
async deleteReverseProxy(id: string): Promise<void> {
if (!this.customObjectsApi) return;
this.requireClients();
await this.deleteResource("reverseproxyservers", operatorResourceName(id));
}
private async syncServer(server: ServerWithEnvVars): Promise<void> {
const name = operatorResourceName(server.id);
const secretName = `${name}-api-key`;
const secretName = `mc-${name}-api-key`;
await this.upsertSecret(secretName, server.api_key, labels(server.id));
await this.upsertResource("minecraftservers", {
apiVersion: `${API_GROUP}/${API_VERSION}`,
kind: "MinecraftServer",
metadata: { name, namespace: this.namespace, labels: labels(server.id) },
metadata: {
name,
namespace: this.namespace,
labels: labels(server.id),
},
spec: {
type: server.type,
description: server.description ?? undefined,
@@ -163,16 +179,21 @@ export class OperatorResourceSync {
apiKeySecretRef: secretName,
},
});
await this.deleteSecret(`${name}-api-key`);
}
private async syncReverseProxy(proxy: ReverseProxyWithEnvVars): Promise<void> {
const name = operatorResourceName(proxy.id);
const secretName = `${name}-api-key`;
const secretName = `rp-${name}-api-key`;
await this.upsertSecret(secretName, proxy.api_key, labels(proxy.id));
await this.upsertResource("reverseproxyservers", {
apiVersion: `${API_GROUP}/${API_VERSION}`,
kind: "ReverseProxyServer",
metadata: { name, namespace: this.namespace, labels: labels(proxy.id) },
metadata: {
name,
namespace: this.namespace,
labels: labels(proxy.id),
},
spec: {
type: proxy.type,
description: proxy.description ?? undefined,
@@ -190,8 +211,11 @@ export class OperatorResourceSync {
jvm: { heapPercent: 80 },
env: proxy.env_variables.map((entry) => ({ name: entry.key, value: entry.value })),
apiKeySecretRef: secretName,
backendURL: this.backendUrl,
pluginURL: proxy.type === "VELOCITY" ? this.velocityPluginUrl : undefined,
},
});
await this.deleteSecret(`${name}-api-key`);
}
private async upsertResource(plural: string, resource: CustomResource): Promise<void> {
@@ -292,7 +316,7 @@ export class OperatorResourceSync {
} catch (error) {
if (!this.isNotFound(error)) throw error;
}
await this.deleteSecret(`${name}-api-key`);
await this.deleteSecret(`${plural === "minecraftservers" ? "mc" : "rp"}-${name}-api-key`);
}
private async deleteSecret(name: string): Promise<void> {
@@ -316,4 +340,10 @@ export class OperatorResourceSync {
error.response.statusCode === 404))
);
}
private requireClients(): void {
if (!this.coreApi || !this.customObjectsApi) {
throw new Error("Kubernetes operator resource synchronization is unavailable");
}
}
}
+2
View File
@@ -16,6 +16,7 @@ export type K8sResource = {
namespace?: string;
age: string;
labels?: Record<string, string>;
annotations?: Record<string, string>;
[key: string]: unknown;
};
@@ -84,6 +85,7 @@ export type CustomResourceSummary = {
namespace?: string;
age: string;
labels?: Record<string, string>;
annotations?: Record<string, string>;
spec?: Record<string, unknown>;
status?: { phase?: string; [key: string]: unknown };
};
+8 -2
View File
@@ -4,9 +4,15 @@ export type User = PrismaUser;
export type CreateUserInput = Prisma.UserCreateInput;
export type UpdateUserInput = Prisma.UserUpdateInput;
export type UpdateUserInput = {
name?: string;
role?: "admin" | "user";
};
export type UpdateSuspensionInput = Prisma.UserUpdateInput;
export type UpdateSuspensionInput = {
isSuspended: boolean;
suspendedUntil?: Date | null;
};
export function isUserSuspended(user: Pick<PrismaUser, "isSuspended" | "suspendedUntil">): boolean {
if (!user.isSuspended) {
@@ -42,7 +42,7 @@ class Main @Inject constructor(private val logger: Logger, private val server: P
private val client = OkHttpClient()
private val apiKey: String = System.getenv("MINIKURA_API_KEY") ?: ""
private val apiUrl: String = System.getenv("MINIKURA_API_URL") ?: "http://localhost:3000/api"
private val websocketUrl: String = System.getenv("MINIKURA_WEBSOCKET_URL") ?: "ws://localhost:3000/api/servers/ws?apiKey=$apiKey"
private val websocketUrl: String = System.getenv("MINIKURA_WEBSOCKET_URL") ?: "ws://localhost:3000/api/servers/ws"
private var acceptingTransfers = AtomicBoolean(false)
private val redisBungeeApi = RedisBungeeAPI.getRedisBungeeApi()
@@ -55,7 +55,7 @@ class Main @Inject constructor(private val logger: Logger, private val server: P
ProxyTransferUtils.logger = logger
ProxyTransferUtils.acceptingTransfers = acceptingTransfers
val client = createWebSocketClient(this, logger, server, websocketUrl)
val client = createWebSocketClient(this, logger, server, websocketUrl, apiKey)
client.connect()
val commandManager: CommandManager = server.commandManager
@@ -231,7 +231,7 @@ class Main @Inject constructor(private val logger: Logger, private val server: P
servers.clear()
for (data in serversData) {
val serverInfo = ServerInfo(data.id, InetSocketAddress("localhost", data.listen_port))
val serverInfo = ServerInfo(data.id, InetSocketAddress(data.connection_address, data.listen_port))
val registeredServer = server.createRawRegisteredServer(serverInfo)
servers[data.id] = registeredServer
this.server.registerServer(registeredServer.serverInfo)
@@ -8,7 +8,7 @@ import java.net.URI
import java.time.Duration
import com.google.gson.JsonParser
class MinikuraWebSocketClient(private val plugin: Main, private val logger: Logger, private val server: ProxyServer, serverUri: URI?) : WebSocketClient(serverUri) {
class MinikuraWebSocketClient(private val plugin: Main, private val logger: Logger, private val server: ProxyServer, serverUri: URI?, headers: Map<String, String>) : WebSocketClient(serverUri, headers) {
override fun onOpen(handshakedata: ServerHandshake) {
logger.info("Connected to WebSocket server at: ${uri}")
@@ -100,4 +100,4 @@ class MinikuraWebSocketClient(private val plugin: Main, private val logger: Logg
logger.error("Failed to schedule refresh command", e)
}
}
}
}
@@ -8,9 +8,10 @@ data class ServerData(
val type: ServerType,
val description: String?,
val listen_port: Int = 25565,
val connection_address: String,
val memory: String = "1G",
val env_variables: List<CustomEnvironmentVariableData> = emptyList(),
val api_key: String,
val created_at: String,
val updated_at: String
)
)
@@ -6,8 +6,8 @@ import com.velocitypowered.api.proxy.ProxyServer
import org.slf4j.Logger
import java.net.URI
fun createWebSocketClient(plugin: Main, logger: Logger, server: ProxyServer, websocketUrl: String): MinikuraWebSocketClient {
fun createWebSocketClient(plugin: Main, logger: Logger, server: ProxyServer, websocketUrl: String, apiKey: String): MinikuraWebSocketClient {
val uri = URI(websocketUrl)
val client = MinikuraWebSocketClient(plugin, logger, server, uri)
val client = MinikuraWebSocketClient(plugin, logger, server, uri, mapOf("Authorization" to "Bearer $apiKey"))
return client
}