mirror of
https://github.com/YuzuZensai/Minikura.git
synced 2026-09-13 10:49:21 +00:00
🐛 fix: harden backend authorization and sync
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+4
-4
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user