diff --git a/apps/backend/src/application/di-container.ts b/apps/backend/src/application/di-container.ts index 202d8f2..8f0d7ce 100644 --- a/apps/backend/src/application/di-container.ts +++ b/apps/backend/src/application/di-container.ts @@ -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 }; diff --git a/apps/backend/src/application/interfaces/user.service.interface.ts b/apps/backend/src/application/interfaces/user.service.interface.ts index 2bf6a7a..c8ee703 100644 --- a/apps/backend/src/application/interfaces/user.service.interface.ts +++ b/apps/backend/src/application/interfaces/user.service.interface.ts @@ -3,7 +3,11 @@ import type { UpdateSuspensionInput, UpdateUserInput, User } from "@minikura/db" export interface IUserService { getUserById(id: string): Promise; getAllUsers(): Promise; - updateUser(id: string, input: UpdateUserInput): Promise; - updateSuspension(id: string, input: UpdateSuspensionInput): Promise; + updateUser(requestingUserId: string, id: string, input: UpdateUserInput): Promise; + updateSuspension( + requestingUserId: string, + id: string, + input: UpdateSuspensionInput + ): Promise; deleteUser(requestingUserId: string, targetUserId: string): Promise; } diff --git a/apps/backend/src/application/services/reverse-proxy.service.ts b/apps/backend/src/application/services/reverse-proxy.service.ts index b5a8a7f..9a461a9 100644 --- a/apps/backend/src/application/services/reverse-proxy.service.ts +++ b/apps/backend/src/application/services/reverse-proxy.service.ts @@ -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 { 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 { await super.deleteEnvVariable(proxyId, key); - await this.operatorResourceSync.syncReverseProxyById(proxyId); + await eventBus.publish(new ReverseProxyUpdatedEvent(proxyId, {})); } async getConnectionInfo(proxyId: string) { diff --git a/apps/backend/src/application/services/server.service.ts b/apps/backend/src/application/services/server.service.ts index 2e74e5f..2cd66dc 100644 --- a/apps/backend/src/application/services/server.service.ts +++ b/apps/backend/src/application/services/server.service.ts @@ -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 { 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 { await super.deleteEnvVariable(serverId, key); - await this.operatorResourceSync.syncServerById(serverId); + await eventBus.publish(new ServerUpdatedEvent(serverId, {})); } async getConnectionInfo(serverId: string) { diff --git a/apps/backend/src/application/services/user.service.test.ts b/apps/backend/src/application/services/user.service.test.ts new file mode 100644 index 0000000..d6e2beb --- /dev/null +++ b/apps/backend/src/application/services/user.service.test.ts @@ -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); + }); +}); diff --git a/apps/backend/src/application/services/user.service.ts b/apps/backend/src/application/services/user.service.ts index 5f04a9f..e912d2e 100644 --- a/apps/backend/src/application/services/user.service.ts +++ b/apps/backend/src/application/services/user.service.ts @@ -23,12 +23,24 @@ export class UserService implements IUserService { return this.userRepo.findAll(); } - async updateUser(id: string, input: UpdateUserInput): Promise { - return this.userRepo.update(id, input); + async updateUser(requestingUserId: string, id: string, input: UpdateUserInput): Promise { + 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 { - const user = await this.userRepo.updateSuspension(id, input); + async updateSuspension( + requestingUserId: string, + id: string, + input: UpdateSuspensionInput + ): Promise { + 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); } } diff --git a/apps/backend/src/domain/repositories/user.repository.ts b/apps/backend/src/domain/repositories/user.repository.ts index 37581bb..cbb7211 100644 --- a/apps/backend/src/domain/repositories/user.repository.ts +++ b/apps/backend/src/domain/repositories/user.repository.ts @@ -3,7 +3,7 @@ import type { UpdateSuspensionInput, UpdateUserInput, User } from "@minikura/db" export interface UserRepository { findById(id: string): Promise; findAll(): Promise; - update(id: string, input: UpdateUserInput): Promise; - updateSuspension(id: string, input: UpdateSuspensionInput): Promise; - delete(id: string): Promise; + updateWithAdminSafety(id: string, input: UpdateUserInput): Promise; + updateSuspensionWithAdminSafety(id: string, input: UpdateSuspensionInput): Promise; + deleteWithAdminSafety(id: string): Promise; } diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 45767a1..1cc9c24 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -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) diff --git a/apps/backend/src/infrastructure/event-bus.test.ts b/apps/backend/src/infrastructure/event-bus.test.ts new file mode 100644 index 0000000..9da87b0 --- /dev/null +++ b/apps/backend/src/infrastructure/event-bus.test.ts @@ -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(); + }); +}); diff --git a/apps/backend/src/infrastructure/repositories/prisma/reverse-proxy.repository.impl.ts b/apps/backend/src/infrastructure/repositories/prisma/reverse-proxy.repository.impl.ts index f1d4940..45b3859 100644 --- a/apps/backend/src/infrastructure/repositories/prisma/reverse-proxy.repository.impl.ts +++ b/apps/backend/src/infrastructure/repositories/prisma/reverse-proxy.repository.impl.ts @@ -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; }); } diff --git a/apps/backend/src/infrastructure/repositories/prisma/server.repository.impl.ts b/apps/backend/src/infrastructure/repositories/prisma/server.repository.impl.ts index 7a401cb..addf331 100644 --- a/apps/backend/src/infrastructure/repositories/prisma/server.repository.impl.ts +++ b/apps/backend/src/infrastructure/repositories/prisma/server.repository.impl.ts @@ -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; }); } diff --git a/apps/backend/src/infrastructure/repositories/prisma/user.repository.impl.ts b/apps/backend/src/infrastructure/repositories/prisma/user.repository.impl.ts index fdae2ba..e58180f 100644 --- a/apps/backend/src/infrastructure/repositories/prisma/user.repository.impl.ts +++ b/apps/backend/src/infrastructure/repositories/prisma/user.repository.impl.ts @@ -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 { return await prisma.user.findUnique({ @@ -14,23 +25,58 @@ export class PrismaUserRepository implements UserRepository { }); } - async update(id: string, input: UpdateUserInput): Promise { - return await prisma.user.update({ - where: { id }, - data: input, - }); + async updateWithAdminSafety(id: string, input: UpdateUserInput): Promise { + 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 { - return await prisma.user.update({ - where: { id }, - data: input, - }); + async updateSuspensionWithAdminSafety(id: string, input: UpdateSuspensionInput): Promise { + 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 { - await prisma.user.delete({ - where: { id }, - }); + async deleteWithAdminSafety(id: string): Promise { + 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" } + ); } } diff --git a/apps/backend/src/middleware/auth-guards.test.ts b/apps/backend/src/middleware/auth-guards.test.ts new file mode 100644 index 0000000..af93f20 --- /dev/null +++ b/apps/backend/src/middleware/auth-guards.test.ts @@ -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"); + }); +}); diff --git a/apps/backend/src/middleware/auth-guards.ts b/apps/backend/src/middleware/auth-guards.ts index 9d4fb96..3b965d0 100644 --- a/apps/backend/src/middleware/auth-guards.ts +++ b/apps/backend/src/middleware/auth-guards.ts @@ -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): 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 }; + }); diff --git a/apps/backend/src/middleware/auth-plugin.ts b/apps/backend/src/middleware/auth-plugin.ts index cec3804..519b73f 100644 --- a/apps/backend/src/middleware/auth-plugin.ts +++ b/apps/backend/src/middleware/auth-plugin.ts @@ -11,19 +11,20 @@ async function getSessionFromHeaders(headers: Headers | Record) }); } -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; diff --git a/apps/backend/src/middleware/auth.ts b/apps/backend/src/middleware/auth.ts index f1ee577..59279e4 100644 --- a/apps/backend/src/middleware/auth.ts +++ b/apps/backend/src/middleware/auth.ts @@ -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", diff --git a/apps/backend/src/routes/bootstrap.ts b/apps/backend/src/routes/bootstrap.ts index da9a202..dc2f833 100644 --- a/apps/backend/src/routes/bootstrap.ts +++ b/apps/backend/src/routes/bootstrap.ts @@ -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" }; } }); diff --git a/apps/backend/src/routes/k8s.ts b/apps/backend/src/routes/k8s.ts index fc1cab5..a490a57 100644 --- a/apps/backend/src/routes/k8s.ts +++ b/apps/backend/src/routes/k8s.ts @@ -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 }) => { diff --git a/apps/backend/src/routes/plugin.ts b/apps/backend/src/routes/plugin.ts index 046f713..39467d6 100644 --- a/apps/backend/src/routes/plugin.ts +++ b/apps/backend/src/routes/plugin.ts @@ -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), + ]); diff --git a/apps/backend/src/routes/reverse-proxy.ts b/apps/backend/src/routes/reverse-proxy.ts index 93469a4..8de7fd7 100644 --- a/apps/backend/src/routes/reverse-proxy.ts +++ b/apps/backend/src/routes/reverse-proxy.ts @@ -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 }; }); diff --git a/apps/backend/src/routes/servers.ts b/apps/backend/src/routes/servers.ts index 4926bdd..d838a07 100644 --- a/apps/backend/src/routes/servers.ts +++ b/apps/backend/src/routes/servers.ts @@ -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 }; close: () => void } + ws: WebSocketClient & { + data?: { headers?: Record }; + 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 }; }); diff --git a/apps/backend/src/routes/terminal.ts b/apps/backend/src/routes/terminal.ts index 5960638..0a5063e 100644 --- a/apps/backend/src/routes/terminal.ts +++ b/apps/backend/src/routes/terminal.ts @@ -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; @@ -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; diff --git a/apps/backend/src/routes/users.ts b/apps/backend/src/routes/users.ts index e5b4251..d002a2f 100644 --- a/apps/backend/src/routes/users.ts +++ b/apps/backend/src/routes/users.ts @@ -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, }); diff --git a/apps/backend/src/schemas/server.schema.ts b/apps/backend/src/schemas/server.schema.ts index 18bf705..ead3bd0 100644 --- a/apps/backend/src/schemas/server.schema.ts +++ b/apps/backend/src/schemas/server.schema.ts @@ -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), diff --git a/apps/backend/src/schemas/user.schema.test.ts b/apps/backend/src/schemas/user.schema.test.ts new file mode 100644 index 0000000..5b7f3b8 --- /dev/null +++ b/apps/backend/src/schemas/user.schema.test.ts @@ -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(); + } + ); +}); diff --git a/apps/backend/src/schemas/user.schema.ts b/apps/backend/src/schemas/user.schema.ts index efd8254..dd73dee 100644 --- a/apps/backend/src/schemas/user.schema.ts +++ b/apps/backend/src/schemas/user.schema.ts @@ -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; diff --git a/apps/backend/src/services/kubernetes/operations/custom-resource.operations.ts b/apps/backend/src/services/kubernetes/operations/custom-resource.operations.ts index f1e3158..cb4c3b2 100644 --- a/apps/backend/src/services/kubernetes/operations/custom-resource.operations.ts +++ b/apps/backend/src/services/kubernetes/operations/custom-resource.operations.ts @@ -10,6 +10,7 @@ interface CustomResourceItem { namespace?: string; creationTimestamp?: string; labels?: Record; + annotations?: Record; }; spec?: Record; 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 ?? {}, })); diff --git a/apps/backend/src/services/operator-resource-sync.test.ts b/apps/backend/src/services/operator-resource-sync.test.ts new file mode 100644 index 0000000..821359e --- /dev/null +++ b/apps/backend/src/services/operator-resource-sync.test.ts @@ -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"); + }); +}); diff --git a/apps/backend/src/services/operator-resource-sync.ts b/apps/backend/src/services/operator-resource-sync.ts index a10179d..01ceaf9 100644 --- a/apps/backend/src/services/operator-resource-sync.ts +++ b/apps/backend/src/services/operator-resource-sync.ts @@ -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; + annotations?: Record; resourceVersion?: string; }; spec: Record; }; 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 { 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 { - 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 { - 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 { - if (!this.customObjectsApi) return; + this.requireClients(); await this.deleteResource("minecraftservers", operatorResourceName(id)); } async deleteReverseProxy(id: string): Promise { - if (!this.customObjectsApi) return; + this.requireClients(); await this.deleteResource("reverseproxyservers", operatorResourceName(id)); } private async syncServer(server: ServerWithEnvVars): Promise { 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 { 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 { @@ -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 { @@ -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"); + } + } } diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 9d90f59..1696616 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -16,6 +16,7 @@ export type K8sResource = { namespace?: string; age: string; labels?: Record; + annotations?: Record; [key: string]: unknown; }; @@ -84,6 +85,7 @@ export type CustomResourceSummary = { namespace?: string; age: string; labels?: Record; + annotations?: Record; spec?: Record; status?: { phase?: string; [key: string]: unknown }; }; diff --git a/packages/db/src/models/user.ts b/packages/db/src/models/user.ts index 1583de9..9f96842 100644 --- a/packages/db/src/models/user.ts +++ b/packages/db/src/models/user.ts @@ -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): boolean { if (!user.isSuspended) { diff --git a/plugins/MinikuraVelocity/src/main/kotlin/cafe/kirameki/minikuraVelocity/Main.kt b/plugins/MinikuraVelocity/src/main/kotlin/cafe/kirameki/minikuraVelocity/Main.kt index b0d9440..3887b04 100644 --- a/plugins/MinikuraVelocity/src/main/kotlin/cafe/kirameki/minikuraVelocity/Main.kt +++ b/plugins/MinikuraVelocity/src/main/kotlin/cafe/kirameki/minikuraVelocity/Main.kt @@ -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) diff --git a/plugins/MinikuraVelocity/src/main/kotlin/cafe/kirameki/minikuraVelocity/MinikuraWebSocketClient.kt b/plugins/MinikuraVelocity/src/main/kotlin/cafe/kirameki/minikuraVelocity/MinikuraWebSocketClient.kt index 296c307..e0f75a5 100644 --- a/plugins/MinikuraVelocity/src/main/kotlin/cafe/kirameki/minikuraVelocity/MinikuraWebSocketClient.kt +++ b/plugins/MinikuraVelocity/src/main/kotlin/cafe/kirameki/minikuraVelocity/MinikuraWebSocketClient.kt @@ -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) : 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) } } -} \ No newline at end of file +} diff --git a/plugins/MinikuraVelocity/src/main/kotlin/cafe/kirameki/minikuraVelocity/models/ServerData.kt b/plugins/MinikuraVelocity/src/main/kotlin/cafe/kirameki/minikuraVelocity/models/ServerData.kt index f7d0608..2e8ead4 100644 --- a/plugins/MinikuraVelocity/src/main/kotlin/cafe/kirameki/minikuraVelocity/models/ServerData.kt +++ b/plugins/MinikuraVelocity/src/main/kotlin/cafe/kirameki/minikuraVelocity/models/ServerData.kt @@ -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 = emptyList(), val api_key: String, val created_at: String, val updated_at: String -) \ No newline at end of file +) diff --git a/plugins/MinikuraVelocity/src/main/kotlin/cafe/kirameki/minikuraVelocity/utils/WebSocketUtils.kt b/plugins/MinikuraVelocity/src/main/kotlin/cafe/kirameki/minikuraVelocity/utils/WebSocketUtils.kt index 1cd2054..ea3d949 100644 --- a/plugins/MinikuraVelocity/src/main/kotlin/cafe/kirameki/minikuraVelocity/utils/WebSocketUtils.kt +++ b/plugins/MinikuraVelocity/src/main/kotlin/cafe/kirameki/minikuraVelocity/utils/WebSocketUtils.kt @@ -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 }