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();
|
const operatorResourceSync = new OperatorResourceSync();
|
||||||
|
|
||||||
export const userService = new UserService(userRepo);
|
export const userService = new UserService(userRepo);
|
||||||
export const serverService = new ServerService(serverRepo, k8sService, operatorResourceSync);
|
export const serverService = new ServerService(serverRepo, k8sService);
|
||||||
export const reverseProxyService = new ReverseProxyService(
|
export const reverseProxyService = new ReverseProxyService(reverseProxyRepo, k8sService);
|
||||||
reverseProxyRepo,
|
|
||||||
k8sService,
|
|
||||||
operatorResourceSync
|
|
||||||
);
|
|
||||||
export const wsService = webSocketService;
|
export const wsService = webSocketService;
|
||||||
export { k8sService, operatorResourceSync };
|
export { k8sService, operatorResourceSync };
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import type { UpdateSuspensionInput, UpdateUserInput, User } from "@minikura/db"
|
|||||||
export interface IUserService {
|
export interface IUserService {
|
||||||
getUserById(id: string): Promise<User>;
|
getUserById(id: string): Promise<User>;
|
||||||
getAllUsers(): Promise<User[]>;
|
getAllUsers(): Promise<User[]>;
|
||||||
updateUser(id: string, input: UpdateUserInput): Promise<User>;
|
updateUser(requestingUserId: string, id: string, input: UpdateUserInput): Promise<User>;
|
||||||
updateSuspension(id: string, input: UpdateSuspensionInput): Promise<User>;
|
updateSuspension(
|
||||||
|
requestingUserId: string,
|
||||||
|
id: string,
|
||||||
|
input: UpdateSuspensionInput
|
||||||
|
): Promise<User>;
|
||||||
deleteUser(requestingUserId: string, targetUserId: string): Promise<void>;
|
deleteUser(requestingUserId: string, targetUserId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ import type {
|
|||||||
ReverseProxyRepository,
|
ReverseProxyRepository,
|
||||||
ReverseProxyUpdateInput,
|
ReverseProxyUpdateInput,
|
||||||
} from "../../domain/repositories/reverse-proxy.repository";
|
} from "../../domain/repositories/reverse-proxy.repository";
|
||||||
|
import { eventBus } from "../../infrastructure/event-bus";
|
||||||
import type { K8sService } from "../../services/k8s";
|
import type { K8sService } from "../../services/k8s";
|
||||||
import {
|
import { operatorResourceName } from "../../services/operator-resource-sync";
|
||||||
type OperatorResourceSync,
|
|
||||||
operatorResourceName,
|
|
||||||
} from "../../services/operator-resource-sync";
|
|
||||||
import type { IReverseProxyService } from "../interfaces/reverse-proxy.service.interface";
|
import type { IReverseProxyService } from "../interfaces/reverse-proxy.service.interface";
|
||||||
import { BaseCrudService } from "./base-crud.service";
|
import { BaseCrudService } from "./base-crud.service";
|
||||||
|
|
||||||
@@ -33,8 +31,7 @@ export class ReverseProxyService
|
|||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
reverseProxyRepo: ReverseProxyRepository,
|
reverseProxyRepo: ReverseProxyRepository,
|
||||||
private k8sService: K8sService,
|
private k8sService: K8sService
|
||||||
private operatorResourceSync: OperatorResourceSync
|
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
reverseProxyRepo,
|
reverseProxyRepo,
|
||||||
@@ -77,12 +74,12 @@ export class ReverseProxyService
|
|||||||
|
|
||||||
override async setEnvVariable(proxyId: string, key: string, value: string): Promise<void> {
|
override async setEnvVariable(proxyId: string, key: string, value: string): Promise<void> {
|
||||||
await super.setEnvVariable(proxyId, key, value);
|
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> {
|
override async deleteEnvVariable(proxyId: string, key: string): Promise<void> {
|
||||||
await super.deleteEnvVariable(proxyId, key);
|
await super.deleteEnvVariable(proxyId, key);
|
||||||
await this.operatorResourceSync.syncReverseProxyById(proxyId);
|
await eventBus.publish(new ReverseProxyUpdatedEvent(proxyId, {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getConnectionInfo(proxyId: string) {
|
async getConnectionInfo(proxyId: string) {
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ import type {
|
|||||||
ServerRepository,
|
ServerRepository,
|
||||||
ServerUpdateInput,
|
ServerUpdateInput,
|
||||||
} from "../../domain/repositories/server.repository";
|
} from "../../domain/repositories/server.repository";
|
||||||
|
import { eventBus } from "../../infrastructure/event-bus";
|
||||||
import type { K8sService } from "../../services/k8s";
|
import type { K8sService } from "../../services/k8s";
|
||||||
import {
|
import { operatorResourceName } from "../../services/operator-resource-sync";
|
||||||
type OperatorResourceSync,
|
|
||||||
operatorResourceName,
|
|
||||||
} from "../../services/operator-resource-sync";
|
|
||||||
import type { IServerService } from "../interfaces/server.service.interface";
|
import type { IServerService } from "../interfaces/server.service.interface";
|
||||||
import { BaseCrudService } from "./base-crud.service";
|
import { BaseCrudService } from "./base-crud.service";
|
||||||
|
|
||||||
@@ -33,8 +31,7 @@ export class ServerService
|
|||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
serverRepo: ServerRepository,
|
serverRepo: ServerRepository,
|
||||||
private k8sService: K8sService,
|
private k8sService: K8sService
|
||||||
private operatorResourceSync: OperatorResourceSync
|
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
serverRepo,
|
serverRepo,
|
||||||
@@ -77,12 +74,12 @@ export class ServerService
|
|||||||
|
|
||||||
override async setEnvVariable(serverId: string, key: string, value: string): Promise<void> {
|
override async setEnvVariable(serverId: string, key: string, value: string): Promise<void> {
|
||||||
await super.setEnvVariable(serverId, key, value);
|
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> {
|
override async deleteEnvVariable(serverId: string, key: string): Promise<void> {
|
||||||
await super.deleteEnvVariable(serverId, key);
|
await super.deleteEnvVariable(serverId, key);
|
||||||
await this.operatorResourceSync.syncServerById(serverId);
|
await eventBus.publish(new ServerUpdatedEvent(serverId, {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getConnectionInfo(serverId: string) {
|
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();
|
return this.userRepo.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUser(id: string, input: UpdateUserInput): Promise<User> {
|
async updateUser(requestingUserId: string, id: string, input: UpdateUserInput): Promise<User> {
|
||||||
return this.userRepo.update(id, input);
|
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> {
|
async updateSuspension(
|
||||||
const user = await this.userRepo.updateSuspension(id, input);
|
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) {
|
if (input.isSuspended) {
|
||||||
const suspendedUntil = input.suspendedUntil instanceof Date ? input.suspendedUntil : null;
|
const suspendedUntil = input.suspendedUntil instanceof Date ? input.suspendedUntil : null;
|
||||||
await eventBus.publish(new UserSuspendedEvent(id, suspendedUntil));
|
await eventBus.publish(new UserSuspendedEvent(id, suspendedUntil));
|
||||||
@@ -42,6 +54,6 @@ export class UserService implements IUserService {
|
|||||||
if (requestingUserId === targetUserId) {
|
if (requestingUserId === targetUserId) {
|
||||||
throw new BusinessRuleError("Cannot delete yourself");
|
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 {
|
export interface UserRepository {
|
||||||
findById(id: string): Promise<User | null>;
|
findById(id: string): Promise<User | null>;
|
||||||
findAll(): Promise<User[]>;
|
findAll(): Promise<User[]>;
|
||||||
update(id: string, input: UpdateUserInput): Promise<User>;
|
updateWithAdminSafety(id: string, input: UpdateUserInput): Promise<User>;
|
||||||
updateSuspension(id: string, input: UpdateSuspensionInput): Promise<User>;
|
updateSuspensionWithAdminSafety(id: string, input: UpdateSuspensionInput): Promise<User>;
|
||||||
delete(id: string): Promise<void>;
|
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";
|
set.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, Cookie";
|
||||||
})
|
})
|
||||||
.options("/*", () => new Response(null, { status: 204 }))
|
.options("/*", () => new Response(null, { status: 204 }))
|
||||||
|
.all("/auth/admin/*", ({ set }) => {
|
||||||
|
set.status = 404;
|
||||||
|
return { message: "Not found" };
|
||||||
|
})
|
||||||
.all("/auth/*", ({ request }) => auth.handler(request))
|
.all("/auth/*", ({ request }) => auth.handler(request))
|
||||||
.use(bootstrapRoutes)
|
.use(bootstrapRoutes)
|
||||||
.use(authPlugin)
|
.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 (!proxy) return null;
|
||||||
|
|
||||||
if (omitSensitive) {
|
if (omitSensitive) {
|
||||||
const { api_key, ...rest } = proxy;
|
const { api_key, env_variables, ...rest } = proxy;
|
||||||
return { ...rest, api_key: "" } as ReverseProxyWithEnvVars;
|
return { ...rest, api_key: "", env_variables: [] } as ReverseProxyWithEnvVars;
|
||||||
}
|
}
|
||||||
|
|
||||||
return proxy;
|
return proxy;
|
||||||
@@ -33,8 +33,8 @@ export class PrismaReverseProxyRepository implements ReverseProxyRepository {
|
|||||||
|
|
||||||
if (omitSensitive) {
|
if (omitSensitive) {
|
||||||
return proxies.map((proxy) => {
|
return proxies.map((proxy) => {
|
||||||
const { api_key, ...rest } = proxy;
|
const { api_key, env_variables, ...rest } = proxy;
|
||||||
return { ...rest, api_key: "" } as ReverseProxyWithEnvVars;
|
return { ...rest, api_key: "", env_variables: [] } as ReverseProxyWithEnvVars;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export class PrismaServerRepository implements ServerRepository {
|
|||||||
if (!server) return null;
|
if (!server) return null;
|
||||||
|
|
||||||
if (omitSensitive) {
|
if (omitSensitive) {
|
||||||
const { api_key, ...rest } = server;
|
const { api_key, env_variables, ...rest } = server;
|
||||||
return { ...rest, api_key: "" } as ServerWithEnvVars;
|
return { ...rest, api_key: "", env_variables: [] } as ServerWithEnvVars;
|
||||||
}
|
}
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
@@ -33,8 +33,8 @@ export class PrismaServerRepository implements ServerRepository {
|
|||||||
|
|
||||||
if (omitSensitive) {
|
if (omitSensitive) {
|
||||||
return servers.map((server) => {
|
return servers.map((server) => {
|
||||||
const { api_key, ...rest } = server;
|
const { api_key, env_variables, ...rest } = server;
|
||||||
return { ...rest, api_key: "" } as ServerWithEnvVars;
|
return { ...rest, api_key: "", env_variables: [] } as ServerWithEnvVars;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { prisma, type UpdateSuspensionInput, type UpdateUserInput, type User } from "@minikura/db";
|
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";
|
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 {
|
export class PrismaUserRepository implements UserRepository {
|
||||||
async findById(id: string): Promise<User | null> {
|
async findById(id: string): Promise<User | null> {
|
||||||
return await prisma.user.findUnique({
|
return await prisma.user.findUnique({
|
||||||
@@ -14,23 +25,58 @@ export class PrismaUserRepository implements UserRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, input: UpdateUserInput): Promise<User> {
|
async updateWithAdminSafety(id: string, input: UpdateUserInput): Promise<User> {
|
||||||
return await prisma.user.update({
|
return prisma.$transaction(
|
||||||
where: { id },
|
async (tx) => {
|
||||||
data: input,
|
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> {
|
async updateSuspensionWithAdminSafety(id: string, input: UpdateSuspensionInput): Promise<User> {
|
||||||
return await prisma.user.update({
|
return prisma.$transaction(
|
||||||
where: { id },
|
async (tx) => {
|
||||||
data: input,
|
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> {
|
async deleteWithAdminSafety(id: string): Promise<void> {
|
||||||
await prisma.user.delete({
|
await prisma.$transaction(
|
||||||
where: { id },
|
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 }) {
|
function authenticatedUser(ctx: { user?: User | null; isSuspended?: boolean }) {
|
||||||
const { user, isSuspended } = ctx;
|
const { user, isSuspended } = ctx;
|
||||||
if (!user) {
|
|
||||||
throw new UnauthorizedError();
|
|
||||||
}
|
|
||||||
if (isSuspended) {
|
if (isSuspended) {
|
||||||
throw new ForbiddenError("Account is suspended");
|
throw new ForbiddenError("Account is suspended");
|
||||||
}
|
}
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
}
|
||||||
return { user };
|
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) => {
|
export const requireAuth = (app: Elysia) => {
|
||||||
return app.derive((ctx: any) => authenticatedUser(ctx));
|
return app.derive((ctx: any) => authenticatedUser(ctx));
|
||||||
};
|
};
|
||||||
@@ -39,3 +45,11 @@ export const requirePluginApiKey = (app: Elysia) => {
|
|||||||
return { pluginAuth: owner };
|
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" })
|
export const authPlugin = new Elysia({ name: "auth" }).derive(
|
||||||
.mount(auth.handler)
|
{ as: "scoped" },
|
||||||
.derive({ as: "scoped" }, async ({ request }) => {
|
async ({ request }) => {
|
||||||
const session = await getSessionFromHeaders(request.headers);
|
const session = await getSessionFromHeaders(request.headers);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
session?.user &&
|
session?.user &&
|
||||||
|
((session.user as unknown as { banned?: boolean }).banned === true ||
|
||||||
isUserSuspended(
|
isUserSuspended(
|
||||||
session.user as unknown as Pick<
|
session.user as unknown as Pick<
|
||||||
{ isSuspended: boolean; suspendedUntil: Date | null },
|
{ isSuspended: boolean; suspendedUntil: Date | null },
|
||||||
"isSuspended" | "suspendedUntil"
|
"isSuspended" | "suspendedUntil"
|
||||||
>
|
>
|
||||||
)
|
))
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
user: null,
|
user: null,
|
||||||
@@ -39,6 +40,7 @@ export const authPlugin = new Elysia({ name: "auth" })
|
|||||||
isAuthenticated: Boolean(session?.user),
|
isAuthenticated: Boolean(session?.user),
|
||||||
isSuspended: false,
|
isSuspended: false,
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export type AuthPlugin = typeof authPlugin;
|
export type AuthPlugin = typeof authPlugin;
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ export const auth = betterAuth({
|
|||||||
provider: "postgresql",
|
provider: "postgresql",
|
||||||
usePlural: false,
|
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()],
|
plugins: [admin(), openAPI()],
|
||||||
trustedOrigins: [webUrl],
|
trustedOrigins: [webUrl],
|
||||||
basePath: "/auth",
|
basePath: "/auth",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { prisma } from "@minikura/db";
|
import { prisma } from "@minikura/db";
|
||||||
import { getErrorMessage } from "@minikura/shared/errors";
|
|
||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
import { logger } from "../infrastructure/logger";
|
import { logger } from "../infrastructure/logger";
|
||||||
import { auth } from "../middleware/auth";
|
import { auth } from "../middleware/auth";
|
||||||
@@ -11,12 +10,7 @@ export const bootstrapRoutes = new Elysia({ prefix: "/bootstrap" })
|
|||||||
return { needsSetup: userCount === 0 };
|
return { needsSetup: userCount === 0 };
|
||||||
})
|
})
|
||||||
.post("/setup", async ({ body, set }) => {
|
.post("/setup", async ({ body, set }) => {
|
||||||
const userCount = await prisma.user.count();
|
try {
|
||||||
if (userCount > 0) {
|
|
||||||
set.status = 400;
|
|
||||||
return { message: "Setup already completed" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const validated = bootstrapSchema.safeParse(body);
|
const validated = bootstrapSchema.safeParse(body);
|
||||||
if (!validated.success) {
|
if (!validated.success) {
|
||||||
const firstError = validated.error.issues[0];
|
const firstError = validated.error.issues[0];
|
||||||
@@ -27,8 +21,11 @@ export const bootstrapRoutes = new Elysia({ prefix: "/bootstrap" })
|
|||||||
}
|
}
|
||||||
const data = validated.data;
|
const data = validated.data;
|
||||||
|
|
||||||
try {
|
const result = await prisma.$transaction(
|
||||||
const result = await auth.api.createUser({
|
async (tx) => {
|
||||||
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(673886947)`;
|
||||||
|
if ((await tx.user.count()) > 0) return null;
|
||||||
|
return auth.api.createUser({
|
||||||
body: {
|
body: {
|
||||||
email: data.email,
|
email: data.email,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
@@ -36,6 +33,14 @@ export const bootstrapRoutes = new Elysia({ prefix: "/bootstrap" })
|
|||||||
role: "admin",
|
role: "admin",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
set.status = 400;
|
||||||
|
return { message: "Setup already completed" };
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.user) {
|
if (!result.user) {
|
||||||
logger.error({ result }, "No user in bootstrap response");
|
logger.error({ result }, "No user in bootstrap response");
|
||||||
@@ -47,6 +52,6 @@ export const bootstrapRoutes = new Elysia({ prefix: "/bootstrap" })
|
|||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
logger.error({ err }, "Bootstrap setup failed");
|
logger.error({ err }, "Bootstrap setup failed");
|
||||||
set.status = 500;
|
set.status = 500;
|
||||||
return { message: getErrorMessage(err) };
|
return { message: "Failed to complete setup" };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { labelKeys } from "@minikura/api";
|
import { labelKeys } from "@minikura/api";
|
||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
import { k8sService } from "../application/di-container";
|
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" })
|
export const k8sRoutes = new Elysia({ prefix: "/k8s" })
|
||||||
.use(requireAuth)
|
.use(requireAdmin)
|
||||||
.get("/status", async () => {
|
.get("/status", async () => {
|
||||||
return k8sService.getConnectionInfo();
|
return k8sService.getConnectionInfo();
|
||||||
})
|
})
|
||||||
@@ -50,11 +51,11 @@ export const k8sRoutes = new Elysia({ prefix: "/k8s" })
|
|||||||
return logs;
|
return logs;
|
||||||
})
|
})
|
||||||
.get("/servers/:serverId/pods", async ({ params }) => {
|
.get("/servers/:serverId/pods", async ({ params }) => {
|
||||||
const labelSelector = `${labelKeys.serverId}=${params.serverId}`;
|
const labelSelector = `${labelKeys.serverId}=${operatorResourceName(params.serverId)}`;
|
||||||
return await k8sService.getPodsByLabel(labelSelector);
|
return await k8sService.getPodsByLabel(labelSelector);
|
||||||
})
|
})
|
||||||
.get("/reverse-proxy/:serverId/pods", async ({ params }) => {
|
.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);
|
return await k8sService.getPodsByLabel(labelSelector);
|
||||||
})
|
})
|
||||||
.get("/services/:serviceName", async ({ params }) => {
|
.get("/services/:serviceName", async ({ params }) => {
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
import { reverseProxyService, serverService } from "../application/di-container";
|
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" })
|
export const pluginRoutes = new Elysia({ prefix: "/plugin" })
|
||||||
.use(requirePluginApiKey)
|
.use(requirePluginKind("reverse-proxy"))
|
||||||
.get("/servers", async () => serverService.getAllServers(true))
|
.get("/servers", async () => {
|
||||||
.get("/reverse-proxy", async () => reverseProxyService.getAllReverseProxies(true));
|
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 { Elysia } from "elysia";
|
||||||
import { reverseProxyService } from "../application/di-container";
|
import { reverseProxyService } from "../application/di-container";
|
||||||
import { requireAuth } from "../middleware/auth-guards";
|
import { assertAdmin, requireAuth } from "../middleware/auth-guards";
|
||||||
import {
|
import {
|
||||||
createReverseProxySchema,
|
createReverseProxySchema,
|
||||||
envVariableSchema,
|
envVariableSchema,
|
||||||
@@ -9,47 +9,53 @@ import {
|
|||||||
|
|
||||||
export const reverseProxyRoutes = new Elysia({ prefix: "/reverse-proxy" })
|
export const reverseProxyRoutes = new Elysia({ prefix: "/reverse-proxy" })
|
||||||
.use(requireAuth)
|
.use(requireAuth)
|
||||||
.get("/", async () => {
|
.get("/", async ({ user }) => {
|
||||||
return await reverseProxyService.getAllReverseProxies(false);
|
return await reverseProxyService.getAllReverseProxies(user.role !== "admin");
|
||||||
})
|
})
|
||||||
|
|
||||||
.get("/:id", async ({ params }) => {
|
.get("/:id", async ({ params, user }) => {
|
||||||
return await reverseProxyService.getReverseProxyById(params.id, false);
|
return await reverseProxyService.getReverseProxyById(params.id, user.role !== "admin");
|
||||||
})
|
})
|
||||||
|
|
||||||
.get("/:id/connection-info", async ({ params }) => {
|
.get("/:id/connection-info", async ({ params }) => {
|
||||||
return await reverseProxyService.getConnectionInfo(params.id);
|
return await reverseProxyService.getConnectionInfo(params.id);
|
||||||
})
|
})
|
||||||
|
|
||||||
.post("/", async ({ body }) => {
|
.post("/", async ({ body, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
const payload = createReverseProxySchema.parse(body);
|
const payload = createReverseProxySchema.parse(body);
|
||||||
const proxy = await reverseProxyService.createReverseProxy(payload);
|
const proxy = await reverseProxyService.createReverseProxy(payload);
|
||||||
return proxy;
|
return proxy;
|
||||||
})
|
})
|
||||||
|
|
||||||
.patch("/:id", async ({ params, body }) => {
|
.patch("/:id", async ({ params, body, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
const payload = updateReverseProxySchema.parse(body);
|
const payload = updateReverseProxySchema.parse(body);
|
||||||
const proxy = await reverseProxyService.updateReverseProxy(params.id, payload);
|
const proxy = await reverseProxyService.updateReverseProxy(params.id, payload);
|
||||||
return proxy;
|
return proxy;
|
||||||
})
|
})
|
||||||
|
|
||||||
.delete("/:id", async ({ params }) => {
|
.delete("/:id", async ({ params, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
await reverseProxyService.deleteReverseProxy(params.id);
|
await reverseProxyService.deleteReverseProxy(params.id);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
})
|
})
|
||||||
|
|
||||||
.get("/:id/env", async ({ params }) => {
|
.get("/:id/env", async ({ params, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
const envVariables = await reverseProxyService.getEnvVariables(params.id);
|
const envVariables = await reverseProxyService.getEnvVariables(params.id);
|
||||||
return { env_variables: envVariables };
|
return { env_variables: envVariables };
|
||||||
})
|
})
|
||||||
|
|
||||||
.post("/:id/env", async ({ params, body }) => {
|
.post("/:id/env", async ({ params, body, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
const payload = envVariableSchema.parse(body);
|
const payload = envVariableSchema.parse(body);
|
||||||
await reverseProxyService.setEnvVariable(params.id, payload.key, payload.value);
|
await reverseProxyService.setEnvVariable(params.id, payload.key, payload.value);
|
||||||
return { success: true };
|
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);
|
await reverseProxyService.deleteEnvVariable(params.id, params.key);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
import { serverService, wsService } from "../application/di-container";
|
import { serverService, wsService } from "../application/di-container";
|
||||||
import { findApiKeyOwner } from "../middleware/api-key";
|
import { bearerToken, findApiKeyOwner } from "../middleware/api-key";
|
||||||
import { requireAuth } from "../middleware/auth-guards";
|
import { assertAdmin, requireAuth } from "../middleware/auth-guards";
|
||||||
import {
|
import {
|
||||||
createServerSchema,
|
createServerSchema,
|
||||||
envVariableSchema,
|
envVariableSchema,
|
||||||
@@ -12,10 +12,13 @@ import type { WebSocketClient } from "../services/websocket";
|
|||||||
export const serverRoutes = new Elysia({ prefix: "/servers" })
|
export const serverRoutes = new Elysia({ prefix: "/servers" })
|
||||||
.ws("/ws", {
|
.ws("/ws", {
|
||||||
async open(
|
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 ?? "");
|
const owner = await findApiKeyOwner(bearerToken(ws.data?.headers?.authorization ?? null));
|
||||||
if (!owner) {
|
if (owner?.kind !== "reverse-proxy") {
|
||||||
ws.close();
|
ws.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -27,47 +30,53 @@ export const serverRoutes = new Elysia({ prefix: "/servers" })
|
|||||||
message() {},
|
message() {},
|
||||||
})
|
})
|
||||||
.use(requireAuth)
|
.use(requireAuth)
|
||||||
.get("/", async () => {
|
.get("/", async ({ user }) => {
|
||||||
return await serverService.getAllServers(false);
|
return await serverService.getAllServers(user.role !== "admin");
|
||||||
})
|
})
|
||||||
|
|
||||||
.get("/:id", async ({ params }) => {
|
.get("/:id", async ({ params, user }) => {
|
||||||
return await serverService.getServerById(params.id, false);
|
return await serverService.getServerById(params.id, user.role !== "admin");
|
||||||
})
|
})
|
||||||
|
|
||||||
.get("/:id/connection-info", async ({ params }) => {
|
.get("/:id/connection-info", async ({ params }) => {
|
||||||
return await serverService.getConnectionInfo(params.id);
|
return await serverService.getConnectionInfo(params.id);
|
||||||
})
|
})
|
||||||
|
|
||||||
.post("/", async ({ body }) => {
|
.post("/", async ({ body, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
const payload = createServerSchema.parse(body);
|
const payload = createServerSchema.parse(body);
|
||||||
const server = await serverService.createServer(payload);
|
const server = await serverService.createServer(payload);
|
||||||
return server;
|
return server;
|
||||||
})
|
})
|
||||||
|
|
||||||
.patch("/:id", async ({ params, body }) => {
|
.patch("/:id", async ({ params, body, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
const payload = updateServerSchema.parse(body);
|
const payload = updateServerSchema.parse(body);
|
||||||
const server = await serverService.updateServer(params.id, payload);
|
const server = await serverService.updateServer(params.id, payload);
|
||||||
return server;
|
return server;
|
||||||
})
|
})
|
||||||
|
|
||||||
.delete("/:id", async ({ params }) => {
|
.delete("/:id", async ({ params, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
await serverService.deleteServer(params.id);
|
await serverService.deleteServer(params.id);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
})
|
})
|
||||||
|
|
||||||
.get("/:id/env", async ({ params }) => {
|
.get("/:id/env", async ({ params, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
const envVariables = await serverService.getEnvVariables(params.id);
|
const envVariables = await serverService.getEnvVariables(params.id);
|
||||||
return { env_variables: envVariables };
|
return { env_variables: envVariables };
|
||||||
})
|
})
|
||||||
|
|
||||||
.post("/:id/env", async ({ params, body }) => {
|
.post("/:id/env", async ({ params, body, user }) => {
|
||||||
|
assertAdmin(user);
|
||||||
const payload = envVariableSchema.parse(body);
|
const payload = envVariableSchema.parse(body);
|
||||||
await serverService.setEnvVariable(params.id, payload.key, payload.value);
|
await serverService.setEnvVariable(params.id, payload.key, payload.value);
|
||||||
return { success: true };
|
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);
|
await serverService.deleteEnvVariable(params.id, params.key);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getErrorMessage } from "@minikura/shared/errors";
|
|||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
import { k8sService } from "../application/di-container";
|
import { k8sService } from "../application/di-container";
|
||||||
import { logger } from "../infrastructure/logger";
|
import { logger } from "../infrastructure/logger";
|
||||||
import { requireAuth } from "../middleware/auth-guards";
|
import { requireAdmin } from "../middleware/auth-guards";
|
||||||
|
|
||||||
type TerminalWsData = {
|
type TerminalWsData = {
|
||||||
query?: Record<string, string>;
|
query?: Record<string, string>;
|
||||||
@@ -26,7 +26,7 @@ type BunTlsOptions = {
|
|||||||
ca?: string;
|
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) => {
|
open: async (ws: TerminalWs) => {
|
||||||
const podName = ws.data.query?.podName;
|
const podName = ws.data.query?.podName;
|
||||||
const container = ws.data.query?.container;
|
const container = ws.data.query?.container;
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ export const userRoutes = new Elysia({ prefix: "/users" })
|
|||||||
.get("/:id", async ({ params }) => {
|
.get("/:id", async ({ params }) => {
|
||||||
return await userService.getUserById(params.id);
|
return await userService.getUserById(params.id);
|
||||||
})
|
})
|
||||||
.patch("/:id", async ({ params, body }) => {
|
.patch("/:id", async ({ params, body, user }) => {
|
||||||
const input = updateUserSchema.parse(body);
|
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);
|
const payload = updateSuspensionSchema.parse(body);
|
||||||
return await userService.updateSuspension(params.id, {
|
return await userService.updateSuspension(user.id, params.id, {
|
||||||
isSuspended: payload.isSuspended,
|
isSuspended: payload.isSuspended,
|
||||||
suspendedUntil: payload.suspendedUntil ? new Date(payload.suspendedUntil) : null,
|
suspendedUntil: payload.suspendedUntil ? new Date(payload.suspendedUntil) : null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,18 +7,16 @@ import {
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { GameMode, ServerDifficulty } from "../domain/entities/enums";
|
import { GameMode, ServerDifficulty } from "../domain/entities/enums";
|
||||||
|
|
||||||
export const serverIdSchema = z.object({
|
const resourceIdSchema = z
|
||||||
id: z
|
|
||||||
.string()
|
.string()
|
||||||
.min(1, "Server ID is required")
|
.min(1, "Server ID is required")
|
||||||
.regex(/^[a-zA-Z0-9-_]+$/, "ID must be alphanumeric with - or _"),
|
.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({
|
export const createServerSchema = z.object({
|
||||||
id: z
|
id: resourceIdSchema,
|
||||||
.string()
|
|
||||||
.min(1, "Server ID is required")
|
|
||||||
.regex(/^[a-zA-Z0-9-_]+$/, "ID must be alphanumeric with - or _"),
|
|
||||||
description: z.string().nullable().optional(),
|
description: z.string().nullable().optional(),
|
||||||
listen_port: z.number().int().min(1).max(65535),
|
listen_port: z.number().int().min(1).max(65535),
|
||||||
type: z.nativeEnum(ServerType),
|
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 updateServerSchema = createServerSchema.omit({ id: true, type: true }).partial();
|
||||||
|
|
||||||
export const createReverseProxySchema = z.object({
|
export const createReverseProxySchema = z.object({
|
||||||
id: z
|
id: resourceIdSchema,
|
||||||
.string()
|
|
||||||
.min(1, "Server ID is required")
|
|
||||||
.regex(/^[a-zA-Z0-9-_]+$/, "ID must be alphanumeric with - or _"),
|
|
||||||
description: z.string().nullable().optional(),
|
description: z.string().nullable().optional(),
|
||||||
external_address: z.string().min(1, "External address is required"),
|
external_address: z.string().min(1, "External address is required"),
|
||||||
external_port: z.number().int().min(1).max(65535),
|
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({
|
export const updateSuspensionSchema = z.object({
|
||||||
isSuspended: z.boolean(),
|
isSuspended: z.boolean(),
|
||||||
suspendedUntil: z.string().nullable().optional(),
|
suspendedUntil: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface CustomResourceItem {
|
|||||||
namespace?: string;
|
namespace?: string;
|
||||||
creationTimestamp?: string;
|
creationTimestamp?: string;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
|
annotations?: Record<string, string>;
|
||||||
};
|
};
|
||||||
spec?: Record<string, unknown>;
|
spec?: Record<string, unknown>;
|
||||||
status?: { phase?: string; [key: string]: unknown };
|
status?: { phase?: string; [key: string]: unknown };
|
||||||
@@ -47,6 +48,7 @@ export class CustomResourceOperations extends BaseK8sOperations {
|
|||||||
namespace: item.metadata?.namespace ?? this.namespace,
|
namespace: item.metadata?.namespace ?? this.namespace,
|
||||||
age: getAge(item.metadata?.creationTimestamp),
|
age: getAge(item.metadata?.creationTimestamp),
|
||||||
labels: item.metadata?.labels,
|
labels: item.metadata?.labels,
|
||||||
|
annotations: item.metadata?.annotations,
|
||||||
spec: item.spec ?? {},
|
spec: item.spec ?? {},
|
||||||
status: item.status ?? {},
|
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 { prisma, type ReverseProxyWithEnvVars, type ServerWithEnvVars } from "@minikura/db";
|
||||||
import { buildKubeConfig } from "@minikura/shared/kube-auth";
|
import { buildKubeConfig } from "@minikura/shared/kube-auth";
|
||||||
import { logger } from "../infrastructure/logger";
|
import { logger } from "../infrastructure/logger";
|
||||||
|
|
||||||
const API_VERSION = "v1alpha1";
|
const API_VERSION = "v1alpha1";
|
||||||
const FIELD_MANAGER = "minikura-backend";
|
const FIELD_MANAGER = "minikura-backend";
|
||||||
const SYNC_INTERVAL_MS = 30_000;
|
const SYNC_INTERVAL_MS = 30_000;
|
||||||
|
const DEFAULT_OPERATOR_BACKEND_URL = "http://minikura-backend:3000/api";
|
||||||
|
|
||||||
type CustomResource = {
|
type CustomResource = {
|
||||||
apiVersion: string;
|
apiVersion: string;
|
||||||
@@ -14,19 +16,17 @@ type CustomResource = {
|
|||||||
name: string;
|
name: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
labels: Record<string, string>;
|
labels: Record<string, string>;
|
||||||
|
annotations?: Record<string, string>;
|
||||||
resourceVersion?: string;
|
resourceVersion?: string;
|
||||||
};
|
};
|
||||||
spec: Record<string, unknown>;
|
spec: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function operatorResourceName(id: string): string {
|
export function operatorResourceName(id: string): string {
|
||||||
const normalized = id
|
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(id) || id.length > 51) {
|
||||||
.toLowerCase()
|
throw new Error(`Invalid Kubernetes resource ID: ${id}`);
|
||||||
.replace(/[^a-z0-9.-]+/g, "-")
|
}
|
||||||
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "")
|
return id;
|
||||||
.slice(0, 63);
|
|
||||||
if (!normalized) throw new Error(`Cannot derive a Kubernetes resource name from ${id}`);
|
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function serviceType(type: string): "ClusterIP" | "NodePort" | "LoadBalancer" {
|
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> {
|
function labels(id: string): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
"app.kubernetes.io/managed-by": FIELD_MANAGER,
|
"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 {
|
export class OperatorResourceSync {
|
||||||
private readonly namespace = process.env.KUBERNETES_NAMESPACE || "minikura";
|
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 coreApi?: k8s.CoreV1Api;
|
||||||
private customObjectsApi?: k8s.CustomObjectsApi;
|
private customObjectsApi?: k8s.CustomObjectsApi;
|
||||||
private syncing = false;
|
private syncing = false;
|
||||||
@@ -72,10 +75,19 @@ export class OperatorResourceSync {
|
|||||||
prisma.server.findMany({ include: { env_variables: true } }),
|
prisma.server.findMany({ include: { env_variables: true } }),
|
||||||
prisma.reverseProxyServer.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)),
|
...servers.map((server) => this.syncServer(server)),
|
||||||
...proxies.map((proxy) => this.syncReverseProxy(proxy)),
|
...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([
|
await Promise.all([
|
||||||
this.deleteStaleResources(
|
this.deleteStaleResources(
|
||||||
"minecraftservers",
|
"minecraftservers",
|
||||||
@@ -94,7 +106,7 @@ export class OperatorResourceSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async syncServerById(id: string): Promise<void> {
|
async syncServerById(id: string): Promise<void> {
|
||||||
if (!this.coreApi || !this.customObjectsApi) return;
|
this.requireClients();
|
||||||
const server = await prisma.server.findUnique({
|
const server = await prisma.server.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { env_variables: true },
|
include: { env_variables: true },
|
||||||
@@ -103,7 +115,7 @@ export class OperatorResourceSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async syncReverseProxyById(id: string): Promise<void> {
|
async syncReverseProxyById(id: string): Promise<void> {
|
||||||
if (!this.coreApi || !this.customObjectsApi) return;
|
this.requireClients();
|
||||||
const proxy = await prisma.reverseProxyServer.findUnique({
|
const proxy = await prisma.reverseProxyServer.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { env_variables: true },
|
include: { env_variables: true },
|
||||||
@@ -112,23 +124,27 @@ export class OperatorResourceSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteServer(id: string): Promise<void> {
|
async deleteServer(id: string): Promise<void> {
|
||||||
if (!this.customObjectsApi) return;
|
this.requireClients();
|
||||||
await this.deleteResource("minecraftservers", operatorResourceName(id));
|
await this.deleteResource("minecraftservers", operatorResourceName(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteReverseProxy(id: string): Promise<void> {
|
async deleteReverseProxy(id: string): Promise<void> {
|
||||||
if (!this.customObjectsApi) return;
|
this.requireClients();
|
||||||
await this.deleteResource("reverseproxyservers", operatorResourceName(id));
|
await this.deleteResource("reverseproxyservers", operatorResourceName(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async syncServer(server: ServerWithEnvVars): Promise<void> {
|
private async syncServer(server: ServerWithEnvVars): Promise<void> {
|
||||||
const name = operatorResourceName(server.id);
|
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.upsertSecret(secretName, server.api_key, labels(server.id));
|
||||||
await this.upsertResource("minecraftservers", {
|
await this.upsertResource("minecraftservers", {
|
||||||
apiVersion: `${API_GROUP}/${API_VERSION}`,
|
apiVersion: `${API_GROUP}/${API_VERSION}`,
|
||||||
kind: "MinecraftServer",
|
kind: "MinecraftServer",
|
||||||
metadata: { name, namespace: this.namespace, labels: labels(server.id) },
|
metadata: {
|
||||||
|
name,
|
||||||
|
namespace: this.namespace,
|
||||||
|
labels: labels(server.id),
|
||||||
|
},
|
||||||
spec: {
|
spec: {
|
||||||
type: server.type,
|
type: server.type,
|
||||||
description: server.description ?? undefined,
|
description: server.description ?? undefined,
|
||||||
@@ -163,16 +179,21 @@ export class OperatorResourceSync {
|
|||||||
apiKeySecretRef: secretName,
|
apiKeySecretRef: secretName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.deleteSecret(`${name}-api-key`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async syncReverseProxy(proxy: ReverseProxyWithEnvVars): Promise<void> {
|
private async syncReverseProxy(proxy: ReverseProxyWithEnvVars): Promise<void> {
|
||||||
const name = operatorResourceName(proxy.id);
|
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.upsertSecret(secretName, proxy.api_key, labels(proxy.id));
|
||||||
await this.upsertResource("reverseproxyservers", {
|
await this.upsertResource("reverseproxyservers", {
|
||||||
apiVersion: `${API_GROUP}/${API_VERSION}`,
|
apiVersion: `${API_GROUP}/${API_VERSION}`,
|
||||||
kind: "ReverseProxyServer",
|
kind: "ReverseProxyServer",
|
||||||
metadata: { name, namespace: this.namespace, labels: labels(proxy.id) },
|
metadata: {
|
||||||
|
name,
|
||||||
|
namespace: this.namespace,
|
||||||
|
labels: labels(proxy.id),
|
||||||
|
},
|
||||||
spec: {
|
spec: {
|
||||||
type: proxy.type,
|
type: proxy.type,
|
||||||
description: proxy.description ?? undefined,
|
description: proxy.description ?? undefined,
|
||||||
@@ -190,8 +211,11 @@ export class OperatorResourceSync {
|
|||||||
jvm: { heapPercent: 80 },
|
jvm: { heapPercent: 80 },
|
||||||
env: proxy.env_variables.map((entry) => ({ name: entry.key, value: entry.value })),
|
env: proxy.env_variables.map((entry) => ({ name: entry.key, value: entry.value })),
|
||||||
apiKeySecretRef: secretName,
|
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> {
|
private async upsertResource(plural: string, resource: CustomResource): Promise<void> {
|
||||||
@@ -292,7 +316,7 @@ export class OperatorResourceSync {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!this.isNotFound(error)) throw 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> {
|
private async deleteSecret(name: string): Promise<void> {
|
||||||
@@ -316,4 +340,10 @@ export class OperatorResourceSync {
|
|||||||
error.response.statusCode === 404))
|
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;
|
namespace?: string;
|
||||||
age: string;
|
age: string;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
|
annotations?: Record<string, string>;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ export type CustomResourceSummary = {
|
|||||||
namespace?: string;
|
namespace?: string;
|
||||||
age: string;
|
age: string;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
|
annotations?: Record<string, string>;
|
||||||
spec?: Record<string, unknown>;
|
spec?: Record<string, unknown>;
|
||||||
status?: { phase?: string; [key: string]: unknown };
|
status?: { phase?: string; [key: string]: unknown };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ export type User = PrismaUser;
|
|||||||
|
|
||||||
export type CreateUserInput = Prisma.UserCreateInput;
|
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 {
|
export function isUserSuspended(user: Pick<PrismaUser, "isSuspended" | "suspendedUntil">): boolean {
|
||||||
if (!user.isSuspended) {
|
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 client = OkHttpClient()
|
||||||
private val apiKey: String = System.getenv("MINIKURA_API_KEY") ?: ""
|
private val apiKey: String = System.getenv("MINIKURA_API_KEY") ?: ""
|
||||||
private val apiUrl: String = System.getenv("MINIKURA_API_URL") ?: "http://localhost:3000/api"
|
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 var acceptingTransfers = AtomicBoolean(false)
|
||||||
private val redisBungeeApi = RedisBungeeAPI.getRedisBungeeApi()
|
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.logger = logger
|
||||||
ProxyTransferUtils.acceptingTransfers = acceptingTransfers
|
ProxyTransferUtils.acceptingTransfers = acceptingTransfers
|
||||||
|
|
||||||
val client = createWebSocketClient(this, logger, server, websocketUrl)
|
val client = createWebSocketClient(this, logger, server, websocketUrl, apiKey)
|
||||||
client.connect()
|
client.connect()
|
||||||
|
|
||||||
val commandManager: CommandManager = server.commandManager
|
val commandManager: CommandManager = server.commandManager
|
||||||
@@ -231,7 +231,7 @@ class Main @Inject constructor(private val logger: Logger, private val server: P
|
|||||||
servers.clear()
|
servers.clear()
|
||||||
|
|
||||||
for (data in serversData) {
|
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)
|
val registeredServer = server.createRawRegisteredServer(serverInfo)
|
||||||
servers[data.id] = registeredServer
|
servers[data.id] = registeredServer
|
||||||
this.server.registerServer(registeredServer.serverInfo)
|
this.server.registerServer(registeredServer.serverInfo)
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import java.net.URI
|
|||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import com.google.gson.JsonParser
|
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) {
|
override fun onOpen(handshakedata: ServerHandshake) {
|
||||||
logger.info("Connected to WebSocket server at: ${uri}")
|
logger.info("Connected to WebSocket server at: ${uri}")
|
||||||
|
|||||||
+1
@@ -8,6 +8,7 @@ data class ServerData(
|
|||||||
val type: ServerType,
|
val type: ServerType,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
val listen_port: Int = 25565,
|
val listen_port: Int = 25565,
|
||||||
|
val connection_address: String,
|
||||||
val memory: String = "1G",
|
val memory: String = "1G",
|
||||||
val env_variables: List<CustomEnvironmentVariableData> = emptyList(),
|
val env_variables: List<CustomEnvironmentVariableData> = emptyList(),
|
||||||
val api_key: String,
|
val api_key: String,
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@ import com.velocitypowered.api.proxy.ProxyServer
|
|||||||
import org.slf4j.Logger
|
import org.slf4j.Logger
|
||||||
import java.net.URI
|
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 uri = URI(websocketUrl)
|
||||||
val client = MinikuraWebSocketClient(plugin, logger, server, uri)
|
val client = MinikuraWebSocketClient(plugin, logger, server, uri, mapOf("Authorization" to "Bearer $apiKey"))
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user