mirror of
https://github.com/YuzuZensai/Minikura.git
synced 2026-09-14 03:09:50 +00:00
✨ feat: initial prototype
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
import { describe, it, expect, beforeEach, mock } from "bun:test";
|
||||
import { SessionService } from "../session";
|
||||
|
||||
// Create mock functions
|
||||
const mockSessionFindUnique = mock();
|
||||
const mockServerFindUnique = mock();
|
||||
const mockReverseProxyFindUnique = mock();
|
||||
const mockIsUserSuspended = mock();
|
||||
|
||||
// Mock the prisma client
|
||||
mock.module("@minikura/db", () => ({
|
||||
prisma: {
|
||||
session: {
|
||||
findUnique: mockSessionFindUnique,
|
||||
},
|
||||
server: {
|
||||
findUnique: mockServerFindUnique,
|
||||
},
|
||||
reverseProxyServer: {
|
||||
findUnique: mockReverseProxyFindUnique,
|
||||
},
|
||||
},
|
||||
isUserSuspended: mockIsUserSuspended,
|
||||
}));
|
||||
|
||||
// Import mocked modules
|
||||
await import("@minikura/db");
|
||||
|
||||
describe("SessionService", () => {
|
||||
beforeEach(() => {
|
||||
mockSessionFindUnique.mockClear();
|
||||
mockServerFindUnique.mockClear();
|
||||
mockReverseProxyFindUnique.mockClear();
|
||||
mockIsUserSuspended.mockClear();
|
||||
});
|
||||
|
||||
describe("validate", () => {
|
||||
it("should return INVALID for non-existent session", async () => {
|
||||
mockSessionFindUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await SessionService.validate("invalid-token");
|
||||
|
||||
expect(result.status).toBe(SessionService.SESSION_STATUS.INVALID);
|
||||
expect(result.session).toBeNull();
|
||||
});
|
||||
|
||||
it("should return EXPIRED for expired session", async () => {
|
||||
const expiredDate = new Date(Date.now() - 1000);
|
||||
mockSessionFindUnique.mockResolvedValue({
|
||||
id: "session-1",
|
||||
token: "token-1",
|
||||
expiresAt: expiredDate,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
userId: "user-1",
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
role: "user",
|
||||
isSuspended: false,
|
||||
suspendedUntil: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await SessionService.validate("expired-token");
|
||||
|
||||
expect(result.status).toBe(SessionService.SESSION_STATUS.EXPIRED);
|
||||
});
|
||||
|
||||
it("should return USER_SUSPENDED for suspended user", async () => {
|
||||
const futureDate = new Date(Date.now() + 1000000);
|
||||
mockIsUserSuspended.mockReturnValue(true);
|
||||
|
||||
mockSessionFindUnique.mockResolvedValue({
|
||||
id: "session-1",
|
||||
token: "token-1",
|
||||
expiresAt: futureDate,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
userId: "user-1",
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
role: "user",
|
||||
isSuspended: true,
|
||||
suspendedUntil: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await SessionService.validate("valid-token");
|
||||
|
||||
expect(result.status).toBe(SessionService.SESSION_STATUS.USER_SUSPENDED);
|
||||
});
|
||||
|
||||
it("should return VALID for valid session with active user", async () => {
|
||||
const futureDate = new Date(Date.now() + 1000000);
|
||||
mockIsUserSuspended.mockReturnValue(false);
|
||||
|
||||
mockSessionFindUnique.mockResolvedValue({
|
||||
id: "session-1",
|
||||
token: "token-1",
|
||||
expiresAt: futureDate,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
userId: "user-1",
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
role: "user",
|
||||
isSuspended: false,
|
||||
suspendedUntil: null,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await SessionService.validate("valid-token");
|
||||
|
||||
expect(result.status).toBe(SessionService.SESSION_STATUS.VALID);
|
||||
expect(result.session).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle temporary suspension correctly", async () => {
|
||||
const futureDate = new Date(Date.now() + 1000000);
|
||||
const pastSuspensionDate = new Date(Date.now() - 1000);
|
||||
|
||||
// User was suspended but suspension has expired
|
||||
mockIsUserSuspended.mockReturnValue(false);
|
||||
|
||||
mockSessionFindUnique.mockResolvedValue({
|
||||
id: "session-1",
|
||||
token: "token-1",
|
||||
expiresAt: futureDate,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
userId: "user-1",
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
role: "user",
|
||||
isSuspended: true,
|
||||
suspendedUntil: pastSuspensionDate,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await SessionService.validate("valid-token");
|
||||
|
||||
expect(result.status).toBe(SessionService.SESSION_STATUS.VALID);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateApiKey", () => {
|
||||
it("should return INVALID for invalid API key format", async () => {
|
||||
const result = await SessionService.validateApiKey("invalid-key");
|
||||
|
||||
expect(result.status).toBe(SessionService.SESSION_STATUS.INVALID);
|
||||
expect(result.server).toBeNull();
|
||||
});
|
||||
|
||||
it("should return INVALID when reverse proxy server not found", async () => {
|
||||
mockReverseProxyFindUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await SessionService.validateApiKey(
|
||||
"minikura_reverse_proxy_server_api_key_invalid"
|
||||
);
|
||||
|
||||
expect(result.status).toBe(SessionService.SESSION_STATUS.INVALID);
|
||||
expect(result.server).toBeNull();
|
||||
});
|
||||
|
||||
it("should return valid reverse proxy server for valid API key", async () => {
|
||||
const mockServer = {
|
||||
id: "server-1",
|
||||
subdomain: "test",
|
||||
api_key: "minikura_reverse_proxy_server_api_key_valid",
|
||||
type: "VELOCITY",
|
||||
description: null,
|
||||
memory: "1G",
|
||||
external_address: "test.example.com",
|
||||
external_port: 25565,
|
||||
listen_port: 25577,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockReverseProxyFindUnique.mockResolvedValue(mockServer);
|
||||
|
||||
const result = await SessionService.validateApiKey(
|
||||
"minikura_reverse_proxy_server_api_key_valid"
|
||||
);
|
||||
|
||||
expect(result.status).toBe(SessionService.SESSION_STATUS.VALID);
|
||||
expect(result.server?.id).toBe(mockServer.id);
|
||||
});
|
||||
|
||||
it("should return valid server for valid server API key", async () => {
|
||||
const mockServer = {
|
||||
id: "server-1",
|
||||
name: "Test Server",
|
||||
api_key: "minikura_server_api_key_valid",
|
||||
type: "STATEFUL",
|
||||
description: null,
|
||||
memory: "2G",
|
||||
listen_port: 25565,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockServerFindUnique.mockResolvedValue(mockServer);
|
||||
|
||||
const result = await SessionService.validateApiKey("minikura_server_api_key_valid");
|
||||
|
||||
expect(result.status).toBe(SessionService.SESSION_STATUS.VALID);
|
||||
expect(result.server?.id).toBe(mockServer.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test";
|
||||
import { UserService } from "../user";
|
||||
|
||||
// Create mock functions
|
||||
const mockFindUnique = mock();
|
||||
const mockFindMany = mock();
|
||||
const mockUpdate = mock();
|
||||
const mockDelete = mock();
|
||||
const mockIsUserSuspended = mock();
|
||||
|
||||
// Mock the prisma client
|
||||
mock.module("@minikura/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: mockFindUnique,
|
||||
findMany: mockFindMany,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
},
|
||||
},
|
||||
isUserSuspended: mockIsUserSuspended,
|
||||
}));
|
||||
|
||||
// Import mocked modules
|
||||
await import("@minikura/db");
|
||||
|
||||
describe("UserService", () => {
|
||||
beforeEach(() => {
|
||||
mockFindUnique.mockClear();
|
||||
mockFindMany.mockClear();
|
||||
mockUpdate.mockClear();
|
||||
mockDelete.mockClear();
|
||||
mockIsUserSuspended.mockClear();
|
||||
});
|
||||
|
||||
describe("getUserByEmail", () => {
|
||||
it("should return user when found", async () => {
|
||||
const mockUser = {
|
||||
id: "user-1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
role: "user",
|
||||
banned: false,
|
||||
isSuspended: false,
|
||||
suspendedUntil: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockFindUnique.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await UserService.getUserByEmail("test@example.com");
|
||||
|
||||
expect(mockFindUnique).toHaveBeenCalledWith({
|
||||
where: { email: "test@example.com" },
|
||||
});
|
||||
expect(result).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it("should return null when user not found", async () => {
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await UserService.getUserByEmail("notfound@example.com");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserById", () => {
|
||||
it("should return user when found", async () => {
|
||||
const mockUser = {
|
||||
id: "user-1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
role: "user",
|
||||
banned: false,
|
||||
isSuspended: false,
|
||||
suspendedUntil: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockFindUnique.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await UserService.getUserById("user-1");
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllUsersWithSuspension", () => {
|
||||
it("should return all users with suspension info", async () => {
|
||||
const mockUsers = [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "User 1",
|
||||
email: "user1@example.com",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
role: "user",
|
||||
banned: false,
|
||||
isSuspended: false,
|
||||
suspendedUntil: null,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
name: "User 2",
|
||||
email: "user2@example.com",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
role: "user",
|
||||
banned: false,
|
||||
isSuspended: true,
|
||||
suspendedUntil: new Date(Date.now() + 86400000),
|
||||
createdAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
mockFindMany.mockResolvedValue(mockUsers);
|
||||
|
||||
const result = await UserService.getAllUsersWithSuspension();
|
||||
|
||||
expect(result).toEqual(mockUsers);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateUser", () => {
|
||||
it("should update user basic information", async () => {
|
||||
const mockUpdatedUser = {
|
||||
id: "user-1",
|
||||
name: "Updated Name",
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
role: "admin",
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
mockUpdate.mockResolvedValue(mockUpdatedUser);
|
||||
|
||||
const result = await UserService.updateUser("user-1", {
|
||||
name: "Updated Name",
|
||||
role: "admin",
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockUpdatedUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe("suspendUser", () => {
|
||||
it("should suspend user indefinitely when no expiration provided", async () => {
|
||||
const mockSuspendedUser = {
|
||||
id: "user-1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
role: "user",
|
||||
isSuspended: true,
|
||||
suspendedUntil: null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
mockUpdate.mockResolvedValue(mockSuspendedUser);
|
||||
|
||||
const result = await UserService.suspendUser("user-1");
|
||||
|
||||
expect(result.isSuspended).toBe(true);
|
||||
expect(result.suspendedUntil).toBeNull();
|
||||
});
|
||||
|
||||
it("should suspend user until specific date", async () => {
|
||||
const suspendUntil = new Date(Date.now() + 86400000); // 1 day from now
|
||||
|
||||
const mockSuspendedUser = {
|
||||
id: "user-1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
role: "user",
|
||||
isSuspended: true,
|
||||
suspendedUntil: suspendUntil,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
mockUpdate.mockResolvedValue(mockSuspendedUser);
|
||||
|
||||
const result = await UserService.suspendUser("user-1", suspendUntil);
|
||||
|
||||
expect(result.isSuspended).toBe(true);
|
||||
expect(result.suspendedUntil).toEqual(suspendUntil);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsuspendUser", () => {
|
||||
it("should unsuspend a user", async () => {
|
||||
const mockUnsuspendedUser = {
|
||||
id: "user-1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
role: "user",
|
||||
isSuspended: false,
|
||||
suspendedUntil: null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
mockUpdate.mockResolvedValue(mockUnsuspendedUser);
|
||||
|
||||
const result = await UserService.unsuspendUser("user-1");
|
||||
|
||||
expect(result.isSuspended).toBe(false);
|
||||
expect(result.suspendedUntil).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteUser", () => {
|
||||
it("should delete a user", async () => {
|
||||
mockDelete.mockResolvedValue({});
|
||||
|
||||
await UserService.deleteUser("user-1");
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkSuspension", () => {
|
||||
it("should return true when user is suspended", async () => {
|
||||
const mockUser = {
|
||||
isSuspended: true,
|
||||
suspendedUntil: null,
|
||||
};
|
||||
|
||||
mockFindUnique.mockResolvedValue(mockUser);
|
||||
mockIsUserSuspended.mockReturnValue(true);
|
||||
|
||||
const result = await UserService.checkSuspension("user-1");
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when user is not suspended", async () => {
|
||||
const mockUser = {
|
||||
isSuspended: false,
|
||||
suspendedUntil: null,
|
||||
};
|
||||
|
||||
mockFindUnique.mockResolvedValue(mockUser);
|
||||
mockIsUserSuspended.mockReturnValue(false);
|
||||
|
||||
const result = await UserService.checkSuspension("user-1");
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should throw error when user not found", async () => {
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(UserService.checkSuspension("user-1")).rejects.toThrow("User not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,455 @@
|
||||
import * as k8s from "@kubernetes/client-node";
|
||||
import type { CustomResourceSummary } from "@minikura/api";
|
||||
import { K8sResources } from "./k8s/resources";
|
||||
|
||||
const CUSTOM_RESOURCE_GROUP = "minikura.kirameki.cafe";
|
||||
const CUSTOM_RESOURCE_VERSION = "v1alpha1";
|
||||
|
||||
export class K8sService {
|
||||
private static instance: K8sService;
|
||||
private kc: k8s.KubeConfig;
|
||||
private coreApi!: k8s.CoreV1Api;
|
||||
private appsApi!: k8s.AppsV1Api;
|
||||
private customObjectsApi!: k8s.CustomObjectsApi;
|
||||
private networkingApi!: k8s.NetworkingV1Api;
|
||||
private namespace: string;
|
||||
private initialized: boolean = false;
|
||||
private resources!: K8sResources;
|
||||
|
||||
private constructor() {
|
||||
this.kc = new k8s.KubeConfig();
|
||||
this.namespace = process.env.KUBERNETES_NAMESPACE || "minikura";
|
||||
|
||||
try {
|
||||
this.setupConfig();
|
||||
this.initializeClients();
|
||||
this.resources = new K8sResources(
|
||||
this.coreApi,
|
||||
this.appsApi,
|
||||
this.networkingApi,
|
||||
this.namespace,
|
||||
);
|
||||
this.initialized = true;
|
||||
} catch (_error) {
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
private setupConfig(): void {
|
||||
const isBun = typeof Bun !== "undefined";
|
||||
|
||||
if (isBun) {
|
||||
const { buildKubeConfig } = require("../lib/kube-auth");
|
||||
this.kc = buildKubeConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.kc.loadFromDefault();
|
||||
console.log("Loaded Kubernetes config from default location");
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"Failed to load Kubernetes config from default location:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.kc.getCurrentContext()) {
|
||||
try {
|
||||
this.kc.loadFromCluster();
|
||||
console.log("Loaded Kubernetes config from cluster");
|
||||
} catch (err) {
|
||||
console.warn("Failed to load Kubernetes config from cluster:", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.kc.getCurrentContext()) {
|
||||
throw new Error(
|
||||
"Failed to setup Kubernetes client - no valid configuration found",
|
||||
);
|
||||
}
|
||||
|
||||
const currentCluster = this.kc.getCurrentCluster();
|
||||
if (currentCluster) {
|
||||
console.log(`Connecting to Kubernetes server: ${currentCluster.server}`);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeClients(): void {
|
||||
this.coreApi = this.kc.makeApiClient(k8s.CoreV1Api);
|
||||
this.appsApi = this.kc.makeApiClient(k8s.AppsV1Api);
|
||||
this.customObjectsApi = this.kc.makeApiClient(k8s.CustomObjectsApi);
|
||||
this.networkingApi = this.kc.makeApiClient(k8s.NetworkingV1Api);
|
||||
}
|
||||
|
||||
static getInstance(): K8sService {
|
||||
if (!K8sService.instance) {
|
||||
K8sService.instance = new K8sService();
|
||||
}
|
||||
return K8sService.instance;
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
getConnectionInfo(): {
|
||||
initialized: boolean;
|
||||
currentContext?: string;
|
||||
cluster?: string;
|
||||
namespace: string;
|
||||
} {
|
||||
if (!this.initialized) {
|
||||
return { initialized: false, namespace: this.namespace };
|
||||
}
|
||||
|
||||
try {
|
||||
const currentContext = this.kc.getCurrentContext();
|
||||
const cluster = this.kc.getCurrentCluster()?.name;
|
||||
return {
|
||||
initialized: true,
|
||||
currentContext,
|
||||
cluster,
|
||||
namespace: this.namespace,
|
||||
};
|
||||
} catch (_error) {
|
||||
return { initialized: false, namespace: this.namespace };
|
||||
}
|
||||
}
|
||||
|
||||
async getPods() {
|
||||
if (!this.initialized) {
|
||||
throw new Error("Kubernetes client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.resources.listPods();
|
||||
} catch (error: unknown) {
|
||||
console.error("Error fetching pods:", error);
|
||||
throw new Error(`Failed to fetch pods: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getDeployments() {
|
||||
if (!this.initialized) {
|
||||
throw new Error("Kubernetes client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.resources.listDeployments();
|
||||
} catch (error: unknown) {
|
||||
console.error("Error fetching deployments:", error);
|
||||
throw new Error(`Failed to fetch deployments: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getStatefulSets() {
|
||||
if (!this.initialized) {
|
||||
throw new Error("Kubernetes client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.resources.listStatefulSets();
|
||||
} catch (error: unknown) {
|
||||
console.error("Error fetching statefulsets:", error);
|
||||
throw new Error(
|
||||
`Failed to fetch statefulsets: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getServices() {
|
||||
if (!this.initialized) {
|
||||
throw new Error("Kubernetes client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.resources.listServices();
|
||||
} catch (error: unknown) {
|
||||
console.error("Error fetching services:", error);
|
||||
throw new Error(`Failed to fetch services: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getConfigMaps() {
|
||||
if (!this.initialized) {
|
||||
throw new Error("Kubernetes client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.resources.listConfigMaps();
|
||||
} catch (error: unknown) {
|
||||
console.error("Error fetching configmaps:", error);
|
||||
throw new Error(`Failed to fetch configmaps: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getIngresses() {
|
||||
if (!this.initialized) {
|
||||
throw new Error("Kubernetes client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.resources.listIngresses();
|
||||
} catch (error: unknown) {
|
||||
console.error("Error fetching ingresses:", error);
|
||||
throw new Error(`Failed to fetch ingresses: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getCustomResources(
|
||||
group: string,
|
||||
version: string,
|
||||
plural: string,
|
||||
): Promise<CustomResourceSummary[]> {
|
||||
if (!this.initialized) {
|
||||
throw new Error("Kubernetes client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
type CustomResourceItem = {
|
||||
metadata?: {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
creationTimestamp?: string;
|
||||
labels?: Record<string, string>;
|
||||
};
|
||||
spec?: Record<string, unknown>;
|
||||
status?: { phase?: string; [key: string]: unknown };
|
||||
};
|
||||
|
||||
const response = await this.customObjectsApi.listNamespacedCustomObject({
|
||||
group,
|
||||
version,
|
||||
namespace: this.namespace,
|
||||
plural,
|
||||
});
|
||||
const body = response as unknown as {
|
||||
items?: CustomResourceItem[];
|
||||
body?: { items?: CustomResourceItem[] };
|
||||
};
|
||||
const items = body.items ?? body.body?.items ?? [];
|
||||
return items.map((item) => ({
|
||||
name: item.metadata?.name,
|
||||
namespace: item.metadata?.namespace,
|
||||
age: getAge(item.metadata?.creationTimestamp),
|
||||
labels: item.metadata?.labels,
|
||||
spec: item.spec,
|
||||
status: item.status,
|
||||
}));
|
||||
} catch (error: unknown) {
|
||||
console.error(`Error fetching custom resources ${plural}:`, error);
|
||||
throw new Error(
|
||||
`Failed to fetch custom resources: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getMinecraftServers() {
|
||||
return this.getCustomResources(
|
||||
CUSTOM_RESOURCE_GROUP,
|
||||
CUSTOM_RESOURCE_VERSION,
|
||||
"minecraftservers",
|
||||
);
|
||||
}
|
||||
|
||||
async getReverseProxyServers() {
|
||||
return this.getCustomResources(
|
||||
CUSTOM_RESOURCE_GROUP,
|
||||
CUSTOM_RESOURCE_VERSION,
|
||||
"reverseproxyservers",
|
||||
);
|
||||
}
|
||||
|
||||
async getPodLogs(
|
||||
podName: string,
|
||||
options?: {
|
||||
container?: string;
|
||||
tailLines?: number;
|
||||
timestamps?: boolean;
|
||||
sinceSeconds?: number;
|
||||
},
|
||||
) {
|
||||
if (!this.initialized) {
|
||||
throw new Error("Kubernetes client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.coreApi.readNamespacedPodLog({
|
||||
name: podName,
|
||||
namespace: this.namespace,
|
||||
container: options?.container,
|
||||
tailLines: options?.tailLines,
|
||||
timestamps: options?.timestamps,
|
||||
sinceSeconds: options?.sinceSeconds,
|
||||
});
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
console.error(`Error fetching logs for pod ${podName}:`, error);
|
||||
throw new Error(`Failed to fetch pod logs: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getPodsByLabel(labelSelector: string) {
|
||||
if (!this.initialized) {
|
||||
throw new Error("Kubernetes client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.resources.listPodsByLabel(labelSelector);
|
||||
} catch (error: unknown) {
|
||||
console.error("Error fetching pods by label:", error);
|
||||
throw new Error(
|
||||
`Failed to fetch pods by label: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getPodInfo(podName: string) {
|
||||
if (!this.initialized) {
|
||||
throw new Error("Kubernetes client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.resources.getPodInfo(podName);
|
||||
} catch (error: unknown) {
|
||||
console.error(`Error fetching pod ${podName}:`, error);
|
||||
throw new Error(`Failed to fetch pod info: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getServiceInfo(serviceName: string) {
|
||||
if (!this.initialized) {
|
||||
throw new Error("Kubernetes client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.resources.getServiceInfo(serviceName);
|
||||
} catch (error: unknown) {
|
||||
console.error(`Error fetching service ${serviceName}:`, error);
|
||||
throw new Error(
|
||||
`Failed to fetch service info: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getNodes() {
|
||||
if (!this.initialized) {
|
||||
throw new Error("Kubernetes client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.resources.listNodes();
|
||||
} catch (error: unknown) {
|
||||
console.error("Error fetching nodes:", error);
|
||||
throw new Error(`Failed to fetch nodes: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getServerConnectionInfo(serviceName: string) {
|
||||
if (!this.initialized) {
|
||||
throw new Error("Kubernetes client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
const service = await this.getServiceInfo(serviceName);
|
||||
const nodes = await this.getNodes();
|
||||
|
||||
if (service.type === "ClusterIP") {
|
||||
return {
|
||||
type: "ClusterIP",
|
||||
ip: service.clusterIP,
|
||||
port: service.ports[0]?.port || null,
|
||||
connectionString:
|
||||
service.clusterIP && service.ports[0]?.port
|
||||
? `${service.clusterIP}:${service.ports[0].port}`
|
||||
: null,
|
||||
note: "Only accessible within the cluster",
|
||||
};
|
||||
}
|
||||
|
||||
if (service.type === "NodePort") {
|
||||
const nodeIP = nodes[0]?.externalIP || nodes[0]?.internalIP;
|
||||
const nodePort = service.ports[0]?.nodePort;
|
||||
return {
|
||||
type: "NodePort",
|
||||
nodeIP,
|
||||
nodePort,
|
||||
port: service.ports[0]?.port || null,
|
||||
connectionString: nodeIP && nodePort ? `${nodeIP}:${nodePort}` : null,
|
||||
note:
|
||||
nodeIP && !nodes[0]?.externalIP
|
||||
? "Using internal IP (may not be accessible from outside the cluster network)"
|
||||
: "Accessible from any node in the cluster",
|
||||
};
|
||||
}
|
||||
|
||||
if (service.type === "LoadBalancer") {
|
||||
const externalIP =
|
||||
service.loadBalancerIP || service.loadBalancerHostname;
|
||||
return {
|
||||
type: "LoadBalancer",
|
||||
externalIP,
|
||||
port: service.ports[0]?.port || null,
|
||||
connectionString:
|
||||
externalIP && service.ports[0]?.port
|
||||
? `${externalIP}:${service.ports[0].port}`
|
||||
: null,
|
||||
note: !externalIP ? "LoadBalancer IP pending" : null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: service.type,
|
||||
note: "Unknown service type",
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`Error fetching connection info for service ${serviceName}:`,
|
||||
error,
|
||||
);
|
||||
throw new Error(
|
||||
`Failed to fetch connection info: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getKubeConfig(): k8s.KubeConfig {
|
||||
return this.kc;
|
||||
}
|
||||
|
||||
getCoreApi(): k8s.CoreV1Api {
|
||||
return this.coreApi;
|
||||
}
|
||||
|
||||
getNamespace(): string {
|
||||
return this.namespace;
|
||||
}
|
||||
}
|
||||
|
||||
function getAge(timestamp: Date | string | undefined): string {
|
||||
if (!timestamp) return "unknown";
|
||||
const now = new Date();
|
||||
const created = new Date(timestamp);
|
||||
const diff = now.getTime() - created.getTime();
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days}d`;
|
||||
if (hours > 0) return `${hours}h`;
|
||||
if (minutes > 0) return `${minutes}m`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
return "Unknown error";
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import type * as k8s from "@kubernetes/client-node";
|
||||
import type {
|
||||
DeploymentInfo,
|
||||
K8sConfigMapSummary,
|
||||
K8sIngressSummary,
|
||||
K8sNodeSummary,
|
||||
K8sServiceInfo,
|
||||
K8sServicePort,
|
||||
K8sServiceSummary,
|
||||
PodDetails,
|
||||
PodInfo,
|
||||
StatefulSetInfo,
|
||||
} from "@minikura/api";
|
||||
|
||||
export class K8sResources {
|
||||
constructor(
|
||||
private readonly coreApi: k8s.CoreV1Api,
|
||||
private readonly appsApi: k8s.AppsV1Api,
|
||||
private readonly networkingApi: k8s.NetworkingV1Api,
|
||||
private readonly namespace: string
|
||||
) {}
|
||||
|
||||
async listPods(): Promise<PodInfo[]> {
|
||||
const response = await this.coreApi.listNamespacedPod({ namespace: this.namespace });
|
||||
return response.items.map((pod) => mapPodInfo(pod));
|
||||
}
|
||||
|
||||
async listPodsByLabel(labelSelector: string): Promise<PodInfo[]> {
|
||||
const response = await this.coreApi.listNamespacedPod({
|
||||
namespace: this.namespace,
|
||||
labelSelector,
|
||||
});
|
||||
return response.items.map((pod) => ({
|
||||
...mapPodInfo(pod),
|
||||
containers: pod.spec?.containers?.map((container) => container.name ?? "") || [],
|
||||
}));
|
||||
}
|
||||
|
||||
async getPodInfo(podName: string): Promise<PodDetails> {
|
||||
const response = await this.coreApi.readNamespacedPod({
|
||||
name: podName,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
const pod = response;
|
||||
return {
|
||||
...mapPodInfo(pod),
|
||||
containers: pod.spec?.containers?.map((container) => container.name ?? "") || [],
|
||||
ip: pod.status?.podIP,
|
||||
conditions:
|
||||
pod.status?.conditions?.map((condition) => ({
|
||||
type: condition.type,
|
||||
status: condition.status,
|
||||
lastTransitionTime: condition.lastTransitionTime
|
||||
? condition.lastTransitionTime.toISOString()
|
||||
: undefined,
|
||||
})) || [],
|
||||
containerStatuses:
|
||||
pod.status?.containerStatuses?.map((status) => ({
|
||||
name: status.name,
|
||||
ready: status.ready,
|
||||
restartCount: status.restartCount,
|
||||
state: status.state
|
||||
? {
|
||||
waiting: status.state.waiting
|
||||
? {
|
||||
reason: status.state.waiting.reason,
|
||||
message: status.state.waiting.message,
|
||||
}
|
||||
: undefined,
|
||||
running: status.state.running
|
||||
? {
|
||||
startedAt: status.state.running.startedAt,
|
||||
}
|
||||
: undefined,
|
||||
terminated: status.state.terminated
|
||||
? {
|
||||
reason: status.state.terminated.reason,
|
||||
exitCode: status.state.terminated.exitCode,
|
||||
finishedAt: status.state.terminated.finishedAt,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
})) || [],
|
||||
};
|
||||
}
|
||||
|
||||
async listDeployments(): Promise<DeploymentInfo[]> {
|
||||
const response = await this.appsApi.listNamespacedDeployment({ namespace: this.namespace });
|
||||
return response.items.map((deployment) => ({
|
||||
name: deployment.metadata?.name ?? "",
|
||||
namespace: deployment.metadata?.namespace,
|
||||
ready: `${deployment.status?.readyReplicas ?? 0}/${deployment.status?.replicas ?? 0}`,
|
||||
desired: deployment.status?.replicas ?? 0,
|
||||
current: deployment.status?.replicas ?? 0,
|
||||
updated: deployment.status?.updatedReplicas ?? 0,
|
||||
upToDate: deployment.status?.updatedReplicas ?? 0,
|
||||
available: deployment.status?.availableReplicas ?? 0,
|
||||
age: getAge(deployment.metadata?.creationTimestamp),
|
||||
labels: deployment.metadata?.labels,
|
||||
}));
|
||||
}
|
||||
|
||||
async listStatefulSets(): Promise<StatefulSetInfo[]> {
|
||||
const response = await this.appsApi.listNamespacedStatefulSet({ namespace: this.namespace });
|
||||
return response.items.map((statefulSet) => ({
|
||||
name: statefulSet.metadata?.name ?? "",
|
||||
namespace: statefulSet.metadata?.namespace,
|
||||
ready: `${statefulSet.status?.readyReplicas ?? 0}/${statefulSet.spec?.replicas ?? 0}`,
|
||||
desired: statefulSet.spec?.replicas ?? 0,
|
||||
current: statefulSet.status?.currentReplicas ?? 0,
|
||||
updated: statefulSet.status?.updatedReplicas ?? 0,
|
||||
age: getAge(statefulSet.metadata?.creationTimestamp),
|
||||
labels: statefulSet.metadata?.labels,
|
||||
}));
|
||||
}
|
||||
|
||||
async listServices(): Promise<K8sServiceSummary[]> {
|
||||
const response = await this.coreApi.listNamespacedService({ namespace: this.namespace });
|
||||
return response.items.map((service) => {
|
||||
const ports = service.spec?.ports ?? [];
|
||||
const portSummary = ports
|
||||
.map((port) => `${port.port}${port.nodePort ? `:${port.nodePort}` : ""}/${port.protocol}`)
|
||||
.join(", ");
|
||||
|
||||
return {
|
||||
name: service.metadata?.name ?? "",
|
||||
namespace: service.metadata?.namespace,
|
||||
type: service.spec?.type,
|
||||
clusterIP: service.spec?.clusterIP ?? null,
|
||||
externalIP:
|
||||
service.status?.loadBalancer?.ingress?.[0]?.ip ||
|
||||
service.spec?.externalIPs?.join(", ") ||
|
||||
"<none>",
|
||||
ports: portSummary,
|
||||
age: getAge(service.metadata?.creationTimestamp),
|
||||
labels: service.metadata?.labels,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async listConfigMaps(): Promise<K8sConfigMapSummary[]> {
|
||||
const response = await this.coreApi.listNamespacedConfigMap({ namespace: this.namespace });
|
||||
return response.items.map((configMap) => ({
|
||||
name: configMap.metadata?.name ?? "",
|
||||
namespace: configMap.metadata?.namespace,
|
||||
data: Object.keys(configMap.data ?? {}).length,
|
||||
age: getAge(configMap.metadata?.creationTimestamp),
|
||||
labels: configMap.metadata?.labels,
|
||||
}));
|
||||
}
|
||||
|
||||
async listIngresses(): Promise<K8sIngressSummary[]> {
|
||||
const response = await this.networkingApi.listNamespacedIngress({ namespace: this.namespace });
|
||||
return response.items.map((ingress) => {
|
||||
const hosts =
|
||||
ingress.spec?.rules
|
||||
?.map((rule) => rule.host)
|
||||
.filter((host): host is string => Boolean(host))
|
||||
.join(", ") || "<none>";
|
||||
const addresses =
|
||||
ingress.status?.loadBalancer?.ingress
|
||||
?.map((item) => item.ip || item.hostname)
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join(", ") || "<pending>";
|
||||
|
||||
return {
|
||||
name: ingress.metadata?.name ?? "",
|
||||
namespace: ingress.metadata?.namespace,
|
||||
className: ingress.spec?.ingressClassName ?? null,
|
||||
hosts,
|
||||
address: addresses,
|
||||
age: getAge(ingress.metadata?.creationTimestamp),
|
||||
labels: ingress.metadata?.labels,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getServiceInfo(serviceName: string): Promise<K8sServiceInfo> {
|
||||
const response = await this.coreApi.readNamespacedService({
|
||||
name: serviceName,
|
||||
namespace: this.namespace,
|
||||
});
|
||||
const service = response;
|
||||
const ports: K8sServicePort[] =
|
||||
service.spec?.ports?.map((port) => ({
|
||||
name: port.name ?? null,
|
||||
protocol: port.protocol ?? null,
|
||||
port: port.port,
|
||||
targetPort: port.targetPort,
|
||||
nodePort: port.nodePort ?? null,
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
name: service.metadata?.name,
|
||||
namespace: service.metadata?.namespace,
|
||||
type: service.spec?.type,
|
||||
clusterIP: service.spec?.clusterIP ?? null,
|
||||
externalIPs: service.spec?.externalIPs || [],
|
||||
loadBalancerIP: service.status?.loadBalancer?.ingress?.[0]?.ip || null,
|
||||
loadBalancerHostname: service.status?.loadBalancer?.ingress?.[0]?.hostname || null,
|
||||
ports,
|
||||
selector: service.spec?.selector,
|
||||
};
|
||||
}
|
||||
|
||||
async listNodes(): Promise<K8sNodeSummary[]> {
|
||||
const response = await this.coreApi.listNode();
|
||||
return response.items.map((node) => {
|
||||
const labels = node.metadata?.labels ?? {};
|
||||
const roles = Object.keys(labels)
|
||||
.filter((label) => label.startsWith("node-role.kubernetes.io/"))
|
||||
.map((label) => label.replace("node-role.kubernetes.io/", ""))
|
||||
.join(",");
|
||||
const addresses = node.status?.addresses ?? [];
|
||||
const internalIP = addresses.find((address) => address.type === "InternalIP")?.address;
|
||||
const externalIP = addresses.find((address) => address.type === "ExternalIP")?.address;
|
||||
const hostname = addresses.find((address) => address.type === "Hostname")?.address;
|
||||
const readyCondition = node.status?.conditions?.find((condition) => condition.type === "Ready");
|
||||
|
||||
return {
|
||||
name: node.metadata?.name,
|
||||
status: readyCondition?.status === "True" ? "Ready" : "NotReady",
|
||||
roles: roles || "<none>",
|
||||
age: getAge(node.metadata?.creationTimestamp),
|
||||
version: node.status?.nodeInfo?.kubeletVersion,
|
||||
internalIP,
|
||||
externalIP,
|
||||
hostname,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function mapPodInfo(pod: k8s.V1Pod): PodInfo {
|
||||
const containerStatuses = pod.status?.containerStatuses ?? [];
|
||||
const readyCount = containerStatuses.filter((status) => status.ready).length;
|
||||
const totalCount = containerStatuses.length;
|
||||
const restarts = containerStatuses.reduce(
|
||||
(accumulator, status) => accumulator + (status.restartCount ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
name: pod.metadata?.name ?? "",
|
||||
namespace: pod.metadata?.namespace,
|
||||
status: pod.status?.phase ?? "Unknown",
|
||||
ready: `${readyCount}/${totalCount}`,
|
||||
restarts,
|
||||
age: getAge(pod.metadata?.creationTimestamp),
|
||||
labels: pod.metadata?.labels,
|
||||
nodeName: pod.spec?.nodeName,
|
||||
};
|
||||
}
|
||||
|
||||
function getAge(timestamp: Date | string | undefined): string {
|
||||
if (!timestamp) return "unknown";
|
||||
const now = new Date();
|
||||
const created = new Date(timestamp);
|
||||
const diff = now.getTime() - created.getTime();
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days}d`;
|
||||
if (hours > 0) return `${hours}h`;
|
||||
if (minutes > 0) return `${minutes}m`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
import { prisma } from "@minikura/db";
|
||||
import type { ServerType } from "@minikura/db";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export namespace ServerService {
|
||||
export async function getAllServers(omitSensitive = false) {
|
||||
if (omitSensitive) {
|
||||
return await prisma.server.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
description: true,
|
||||
listen_port: true,
|
||||
memory: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
env_variables: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return await prisma.server.findMany({
|
||||
include: {
|
||||
env_variables: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllReverseProxyServers(omitSensitive = false) {
|
||||
if (omitSensitive) {
|
||||
return await prisma.reverseProxyServer.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
description: true,
|
||||
external_address: true,
|
||||
external_port: true,
|
||||
listen_port: true,
|
||||
memory: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
env_variables: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return await prisma.reverseProxyServer.findMany({
|
||||
include: {
|
||||
env_variables: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServerById(id: string, omitSensitive = false) {
|
||||
if (omitSensitive) {
|
||||
return await prisma.server.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
description: true,
|
||||
listen_port: true,
|
||||
memory: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
env_variables: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return await prisma.server.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
env_variables: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getReverseProxyServerById(
|
||||
id: string,
|
||||
omitSensitive = false
|
||||
) {
|
||||
if (omitSensitive) {
|
||||
return await prisma.reverseProxyServer.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
description: true,
|
||||
external_address: true,
|
||||
external_port: true,
|
||||
listen_port: true,
|
||||
memory: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
env_variables: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return await prisma.reverseProxyServer.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
env_variables: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function createReverseProxyServer({
|
||||
id,
|
||||
description,
|
||||
external_address,
|
||||
external_port,
|
||||
listen_port,
|
||||
type,
|
||||
env_variables,
|
||||
memory,
|
||||
}: {
|
||||
id: string;
|
||||
description: string | null;
|
||||
external_address: string;
|
||||
external_port: number;
|
||||
listen_port?: number;
|
||||
type?: "VELOCITY" | "BUNGEECORD";
|
||||
env_variables?: { key: string; value: string }[];
|
||||
memory?: string;
|
||||
}) {
|
||||
let token = crypto.randomBytes(64).toString("hex");
|
||||
token = token
|
||||
.split("")
|
||||
.map((char) => (Math.random() > 0.5 ? char.toUpperCase() : char))
|
||||
.join("");
|
||||
token = `minikura_reverse_proxy_server_api_key_${token}`;
|
||||
|
||||
return await prisma.reverseProxyServer.create({
|
||||
data: {
|
||||
id,
|
||||
description,
|
||||
external_address,
|
||||
external_port,
|
||||
listen_port: listen_port || 25565,
|
||||
type: type || "VELOCITY",
|
||||
api_key: token,
|
||||
memory: memory || "512M",
|
||||
env_variables: env_variables ? {
|
||||
create: env_variables.map(ev => ({
|
||||
key: ev.key,
|
||||
value: ev.value
|
||||
}))
|
||||
} : undefined,
|
||||
},
|
||||
include: {
|
||||
env_variables: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function createServer({
|
||||
id,
|
||||
description,
|
||||
type,
|
||||
listen_port,
|
||||
env_variables,
|
||||
memory,
|
||||
}: {
|
||||
id: string;
|
||||
description: string | null;
|
||||
type: ServerType;
|
||||
listen_port: number;
|
||||
env_variables?: { key: string; value: string }[];
|
||||
memory?: string;
|
||||
}) {
|
||||
let token = crypto.randomBytes(64).toString("hex");
|
||||
token = token
|
||||
.split("")
|
||||
.map((char) => (Math.random() > 0.5 ? char.toUpperCase() : char))
|
||||
.join("");
|
||||
token = `minikura_server_api_key_${token}`;
|
||||
|
||||
return await prisma.server.create({
|
||||
data: {
|
||||
id,
|
||||
description,
|
||||
type,
|
||||
listen_port,
|
||||
api_key: token,
|
||||
memory: memory || "1G",
|
||||
env_variables: env_variables ? {
|
||||
create: env_variables.map(ev => ({
|
||||
key: ev.key,
|
||||
value: ev.value
|
||||
}))
|
||||
} : undefined,
|
||||
},
|
||||
include: {
|
||||
env_variables: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function setServerEnvironmentVariable(
|
||||
serverId: string,
|
||||
key: string,
|
||||
value: string
|
||||
) {
|
||||
// Upsert pattern - create if doesn't exist, update if it does
|
||||
return await prisma.customEnvironmentVariable.upsert({
|
||||
where: {
|
||||
key_server_id: {
|
||||
key,
|
||||
server_id: serverId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value,
|
||||
},
|
||||
create: {
|
||||
key,
|
||||
value,
|
||||
server_id: serverId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function setReverseProxyEnvironmentVariable(
|
||||
proxyId: string,
|
||||
key: string,
|
||||
value: string
|
||||
) {
|
||||
// Upsert pattern - create if doesn't exist, update if it does
|
||||
return await prisma.customEnvironmentVariable.upsert({
|
||||
where: {
|
||||
key_reverse_proxy_id: {
|
||||
key,
|
||||
reverse_proxy_id: proxyId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value,
|
||||
},
|
||||
create: {
|
||||
key,
|
||||
value,
|
||||
reverse_proxy_id: proxyId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteServerEnvironmentVariable(
|
||||
serverId: string,
|
||||
key: string
|
||||
) {
|
||||
return await prisma.customEnvironmentVariable.delete({
|
||||
where: {
|
||||
key_server_id: {
|
||||
key,
|
||||
server_id: serverId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteReverseProxyEnvironmentVariable(
|
||||
proxyId: string,
|
||||
key: string
|
||||
) {
|
||||
return await prisma.customEnvironmentVariable.delete({
|
||||
where: {
|
||||
key_reverse_proxy_id: {
|
||||
key,
|
||||
reverse_proxy_id: proxyId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { prisma } from "@minikura/db";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export namespace SessionService {
|
||||
export enum SESSION_STATUS {
|
||||
VALID = "VALID",
|
||||
INVALID = "INVALID",
|
||||
REVOKED = "REVOKED",
|
||||
EXPIRED = "EXPIRED",
|
||||
}
|
||||
|
||||
export async function validate(token: string) {
|
||||
const session = await prisma.session.findUnique({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
status: SESSION_STATUS.INVALID,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (session.revoked) {
|
||||
return {
|
||||
status: SESSION_STATUS.REVOKED,
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
if (session.expires_at < new Date()) {
|
||||
return {
|
||||
status: SESSION_STATUS.EXPIRED,
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: SESSION_STATUS.VALID,
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateApiKey(apiKey: string) {
|
||||
// If starts with "minikura_reverse_proxy_server_api_key_"
|
||||
if (apiKey.startsWith("minikura_reverse_proxy_server_api_key_")) {
|
||||
const reverseProxyServer = await prisma.reverseProxyServer.findUnique({
|
||||
where: {
|
||||
api_key: apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!reverseProxyServer) {
|
||||
return {
|
||||
status: SESSION_STATUS.INVALID,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: SESSION_STATUS.VALID,
|
||||
server: reverseProxyServer,
|
||||
};
|
||||
}
|
||||
|
||||
if (apiKey.startsWith("minikura_server_api_key_")) {
|
||||
const server = await prisma.server.findUnique({
|
||||
where: {
|
||||
api_key: apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!server) {
|
||||
return {
|
||||
status: SESSION_STATUS.INVALID,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: SESSION_STATUS.VALID,
|
||||
server: server,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: SESSION_STATUS.INVALID,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function create(userId: string) {
|
||||
let token = crypto.randomBytes(64).toString("hex");
|
||||
token = token
|
||||
.split("")
|
||||
.map((char) => (Math.random() > 0.5 ? char.toUpperCase() : char))
|
||||
.join("");
|
||||
token = `minikura_user_session_${token}`;
|
||||
|
||||
return await prisma.session.create({
|
||||
data: {
|
||||
token,
|
||||
user: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
// Expires in 48 hours
|
||||
expires_at: new Date(Date.now() + 48 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function revoke(token: string) {
|
||||
return await prisma.session.update({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
data: {
|
||||
revoked: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { prisma } from "@minikura/db";
|
||||
|
||||
export namespace UserService {
|
||||
export async function getUserByUsername(username: string) {
|
||||
return await prisma.user.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export type WebSocketClient = {
|
||||
send: (message: string) => void;
|
||||
};
|
||||
|
||||
export interface IWebSocketService {
|
||||
addClient(client: WebSocketClient): void;
|
||||
removeClient(client: WebSocketClient): void;
|
||||
broadcast(action: string, serverType: string, serverId: string): void;
|
||||
getClientCount(): number;
|
||||
}
|
||||
|
||||
export class WebSocketService implements IWebSocketService {
|
||||
private clients = new Set<WebSocketClient>();
|
||||
|
||||
addClient(client: WebSocketClient): void {
|
||||
this.clients.add(client);
|
||||
console.log(`[WebSocket] Client connected (total: ${this.clients.size})`);
|
||||
}
|
||||
|
||||
removeClient(client: WebSocketClient): void {
|
||||
this.clients.delete(client);
|
||||
console.log(
|
||||
`[WebSocket] Client disconnected (total: ${this.clients.size})`,
|
||||
);
|
||||
}
|
||||
|
||||
broadcast(action: string, serverType: string, serverId: string): void {
|
||||
const message = JSON.stringify({
|
||||
type: "SERVER_CHANGE",
|
||||
action,
|
||||
serverType,
|
||||
serverId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
let failedClients = 0;
|
||||
this.clients.forEach((client) => {
|
||||
try {
|
||||
client.send(message);
|
||||
} catch {
|
||||
failedClients++;
|
||||
this.clients.delete(client);
|
||||
}
|
||||
});
|
||||
|
||||
if (failedClients > 0) {
|
||||
console.log(`[WebSocket] Removed ${failedClients} failed clients`);
|
||||
}
|
||||
}
|
||||
|
||||
getClientCount(): number {
|
||||
return this.clients.size;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user