This commit is contained in:
2025-12-10 02:55:07 +08:00
parent 58d28366bb
commit 7ad20497d8
12 changed files with 2925 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
# Nakama Server Setup for Tekton
This guide explains how to deploy the admin module to your Nakama server.
## Files
- `tekton_admin.ts` - TypeScript server runtime module
## Prerequisites
1. Nakama server installed and running
2. Node.js and TypeScript for compilation (if using TypeScript)
## Deployment Steps
### Option 1: TypeScript (Recommended)
1. **Install dependencies:**
```bash
npm install -g typescript
```
2. **Compile TypeScript to JavaScript:**
```bash
cd server/nakama
tsc tekton_admin.ts --outDir dist --lib ES2020 --types nakama-runtime
```
3. **Copy to Nakama modules directory:**
```bash
cp dist/tekton_admin.js /path/to/nakama/data/modules/
```
4. **Restart Nakama server**
### Option 2: Convert to Lua
If you prefer Lua, the TypeScript can be converted. See Nakama docs.
## Configuration
In your `nakama.yml` or `nakama-docker.yml`:
```yaml
runtime:
js_entrypoint: "tekton_admin.js"
```
Or for multiple modules:
```yaml
runtime:
js_entrypoint: "index.js"
```
## Role System
User roles are stored in account metadata:
```json
{
"role": "player" | "moderator" | "admin" | "owner"
}
```
### Setting Initial Admin
Run this in Nakama console or via API:
```sql
UPDATE users
SET metadata = '{"role": "owner"}'
WHERE username = 'your_admin_username';
```
Or via Nakama HTTP API:
```bash
curl -X PUT "http://localhost:7350/v2/console/account/{user_id}" \
-H "Authorization: Bearer {admin_token}" \
-d '{"metadata": "{\"role\": \"owner\"}"}'
```
## RPC Endpoints
| Endpoint | Required Role | Description |
|----------|--------------|-------------|
| `admin_kick_player` | Host or Admin | Kick from match |
| `admin_ban_player` | Admin only | Ban user |
| `admin_unban_player` | Admin only | Unban user |
| `admin_get_ban_list` | Admin only | Get all bans |
| `admin_get_server_stats` | Host or Admin | Server statistics |
| `admin_end_match` | Host or Admin | End current match |
| `admin_set_user_role` | Owner only | Change user roles |
## Security Notes
1. **Never trust the client** - All admin operations are validated server-side
2. **Rate limiting** - Consider adding rate limits for RPC calls
3. **Logging** - All admin actions are logged with user ID
4. **Ban storage** - Bans are stored in Nakama storage, not local files
## Testing
1. Create a test user with admin role
2. Login with the admin user
3. Press F10 in-game to open admin panel
4. Verify kick/ban operations work
## Troubleshooting
### RPC Not Found
- Check module is loaded: `nakama --help` or check logs
- Verify js_entrypoint in config
### Permission Denied
- Check user has correct role in metadata
- Verify session is valid
### Ban Not Working
- Check Nakama storage permissions
- Verify user_id is correct format (UUID)
+550
View File
@@ -0,0 +1,550 @@
/**
* Tekton Nakama Server Runtime Module
*
* This module provides secure admin operations via RPC calls.
* Deploy this to your Nakama server's runtime directory.
*
* For TypeScript modules, compile to JavaScript and place in:
* - data/modules/ (for Nakama Docker)
* - Or configure in nakama config.yml
*/
// Initialize RPC endpoints
function InitModule(
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
initializer: nkruntime.Initializer
) {
// Admin RPCs
initializer.registerRpc("admin_kick_player", rpcAdminKickPlayer);
initializer.registerRpc("admin_ban_player", rpcAdminBanPlayer);
initializer.registerRpc("admin_unban_player", rpcAdminUnbanPlayer);
initializer.registerRpc("admin_get_ban_list", rpcAdminGetBanList);
initializer.registerRpc("admin_get_server_stats", rpcAdminGetServerStats);
initializer.registerRpc("admin_get_player_list", rpcAdminGetPlayerList);
initializer.registerRpc("admin_end_match", rpcAdminEndMatch);
initializer.registerRpc("admin_set_user_role", rpcAdminSetUserRole);
// User management RPCs
initializer.registerRpc("get_user_profile", rpcGetUserProfile);
initializer.registerRpc("update_user_profile", rpcUpdateUserProfile);
logger.info("Tekton admin module loaded");
}
// =============================================================================
// Authorization Helpers
// =============================================================================
interface UserMetadata {
role?: string;
banned?: boolean;
ban_reason?: string;
ban_expires?: string;
}
const ADMIN_ROLES = ["admin", "moderator", "owner"];
function isAdmin(ctx: nkruntime.Context, nk: nkruntime.Nakama): boolean {
if (!ctx.userId) return false;
try {
const account = nk.accountGetId(ctx.userId);
const metadata = JSON.parse(account.user.metadata || "{}") as UserMetadata;
return ADMIN_ROLES.includes(metadata.role || "");
} catch (e) {
return false;
}
}
function isMatchHost(
ctx: nkruntime.Context,
nk: nkruntime.Nakama,
matchId: string
): boolean {
if (!ctx.userId || !matchId) return false;
try {
// Get match state to check host
const match = nk.matchGet(matchId);
if (!match) return false;
// The first user to join (presence) is typically the host
// This logic may need adjustment based on your match handler
const state = JSON.parse(match.state || "{}");
return state.hostUserId === ctx.userId;
} catch (e) {
return false;
}
}
function requireAdmin(ctx: nkruntime.Context, nk: nkruntime.Nakama): void {
if (!isAdmin(ctx, nk)) {
throw new Error("Admin privileges required");
}
}
function requireAdminOrHost(
ctx: nkruntime.Context,
nk: nkruntime.Nakama,
matchId: string
): void {
if (!isAdmin(ctx, nk) && !isMatchHost(ctx, nk, matchId)) {
throw new Error("Admin or host privileges required");
}
}
// =============================================================================
// Admin RPCs
// =============================================================================
interface KickPlayerRequest {
match_id: string;
user_id: string;
reason?: string;
}
function rpcAdminKickPlayer(
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
payload: string
): string {
const request = JSON.parse(payload) as KickPlayerRequest;
requireAdminOrHost(ctx, nk, request.match_id);
// Can't kick yourself
if (request.user_id === ctx.userId) {
throw new Error("Cannot kick yourself");
}
try {
// Signal the match to kick the player
nk.matchSignal(request.match_id, JSON.stringify({
action: "kick",
user_id: request.user_id,
reason: request.reason || "Kicked by admin"
}));
logger.info(`Player ${request.user_id} kicked from match ${request.match_id} by ${ctx.userId}`);
return JSON.stringify({ success: true });
} catch (e) {
logger.error(`Failed to kick player: ${e}`);
throw new Error("Failed to kick player");
}
}
interface BanPlayerRequest {
user_id: string;
reason?: string;
duration_hours?: number; // 0 = permanent
match_id?: string; // Optional: also kick from current match
}
function rpcAdminBanPlayer(
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
payload: string
): string {
const request = JSON.parse(payload) as BanPlayerRequest;
// Only full admins can ban (not just match hosts)
requireAdmin(ctx, nk);
if (request.user_id === ctx.userId) {
throw new Error("Cannot ban yourself");
}
try {
// Get target user's account
const targetAccount = nk.accountGetId(request.user_id);
const metadata = JSON.parse(targetAccount.user.metadata || "{}") as UserMetadata;
// Don't allow banning other admins
if (ADMIN_ROLES.includes(metadata.role || "")) {
throw new Error("Cannot ban an admin");
}
// Set ban in metadata
const banExpires = request.duration_hours && request.duration_hours > 0
? new Date(Date.now() + request.duration_hours * 60 * 60 * 1000).toISOString()
: null;
metadata.banned = true;
metadata.ban_reason = request.reason || "Banned by admin";
if (banExpires) {
metadata.ban_expires = banExpires;
}
nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata));
// Also kick from current match if specified
if (request.match_id) {
nk.matchSignal(request.match_id, JSON.stringify({
action: "kick",
user_id: request.user_id,
reason: "Banned: " + (request.reason || "")
}));
}
// Store in ban list (for quick lookup)
const banRecord = {
user_id: request.user_id,
username: targetAccount.user.username,
banned_by: ctx.userId,
banned_at: new Date().toISOString(),
reason: request.reason,
expires: banExpires
};
nk.storageWrite([{
collection: "bans",
key: request.user_id,
userId: "00000000-0000-0000-0000-000000000000", // System-owned
value: banRecord,
permissionRead: 2, // Public read
permissionWrite: 0 // No one can write (except server)
}]);
logger.warn(`Player ${request.user_id} banned by ${ctx.userId}. Reason: ${request.reason}`);
return JSON.stringify({ success: true, ban: banRecord });
} catch (e) {
logger.error(`Failed to ban player: ${e}`);
throw new Error(`Failed to ban player: ${e}`);
}
}
interface UnbanPlayerRequest {
user_id: string;
}
function rpcAdminUnbanPlayer(
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
payload: string
): string {
const request = JSON.parse(payload) as UnbanPlayerRequest;
requireAdmin(ctx, nk);
try {
// Get target user's account
const targetAccount = nk.accountGetId(request.user_id);
const metadata = JSON.parse(targetAccount.user.metadata || "{}") as UserMetadata;
// Remove ban
delete metadata.banned;
delete metadata.ban_reason;
delete metadata.ban_expires;
nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata));
// Remove from ban list
nk.storageDelete([{
collection: "bans",
key: request.user_id,
userId: "00000000-0000-0000-0000-000000000000"
}]);
logger.info(`Player ${request.user_id} unbanned by ${ctx.userId}`);
return JSON.stringify({ success: true });
} catch (e) {
logger.error(`Failed to unban player: ${e}`);
throw new Error("Failed to unban player");
}
}
function rpcAdminGetBanList(
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
payload: string
): string {
requireAdmin(ctx, nk);
try {
const result = nk.storageList(
"00000000-0000-0000-0000-000000000000",
"bans",
100,
""
);
const bans = result.objects?.map(obj => obj.value) || [];
return JSON.stringify({ bans });
} catch (e) {
logger.error(`Failed to get ban list: ${e}`);
return JSON.stringify({ bans: [] });
}
}
interface GetServerStatsRequest {
match_id?: string;
}
function rpcAdminGetServerStats(
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
payload: string
): string {
const request = JSON.parse(payload || "{}") as GetServerStatsRequest;
if (request.match_id) {
requireAdminOrHost(ctx, nk, request.match_id);
} else {
requireAdmin(ctx, nk);
}
try {
// Get server-wide stats
const matches = nk.matchList(100, true, null, null, null, null);
const activeMatchCount = matches?.length || 0;
let totalPlayers = 0;
if (matches) {
for (const match of matches) {
totalPlayers += match.size || 0;
}
}
const stats = {
active_matches: activeMatchCount,
total_players: totalPlayers,
server_time: new Date().toISOString()
};
// If specific match requested, include match details
if (request.match_id) {
try {
const match = nk.matchGet(request.match_id);
if (match) {
(stats as any).match = {
id: match.matchId,
size: match.size,
tick_rate: match.tickRate,
authoritative: match.authoritative
};
}
} catch (e) {
// Match not found
}
}
return JSON.stringify(stats);
} catch (e) {
logger.error(`Failed to get server stats: ${e}`);
throw new Error("Failed to get server stats");
}
}
interface GetPlayerListRequest {
match_id: string;
}
function rpcAdminGetPlayerList(
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
payload: string
): string {
const request = JSON.parse(payload) as GetPlayerListRequest;
requireAdminOrHost(ctx, nk, request.match_id);
try {
const match = nk.matchGet(request.match_id);
if (!match) {
throw new Error("Match not found");
}
// Get player details
const players: any[] = [];
// Note: In actual implementation, you'd need to track presences
// This is a simplified version - adjust based on your match handler
return JSON.stringify({ players });
} catch (e) {
logger.error(`Failed to get player list: ${e}`);
throw new Error("Failed to get player list");
}
}
interface EndMatchRequest {
match_id: string;
reason?: string;
}
function rpcAdminEndMatch(
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
payload: string
): string {
const request = JSON.parse(payload) as EndMatchRequest;
requireAdminOrHost(ctx, nk, request.match_id);
try {
// Signal match to end
nk.matchSignal(request.match_id, JSON.stringify({
action: "end_match",
reason: request.reason || "Ended by admin"
}));
logger.info(`Match ${request.match_id} ended by ${ctx.userId}`);
return JSON.stringify({ success: true });
} catch (e) {
logger.error(`Failed to end match: ${e}`);
throw new Error("Failed to end match");
}
}
interface SetUserRoleRequest {
user_id: string;
role: string; // "player", "moderator", "admin"
}
function rpcAdminSetUserRole(
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
payload: string
): string {
const request = JSON.parse(payload) as SetUserRoleRequest;
// Only owner/super-admin can set roles
const callerAccount = nk.accountGetId(ctx.userId!);
const callerMetadata = JSON.parse(callerAccount.user.metadata || "{}");
if (callerMetadata.role !== "owner") {
throw new Error("Only owners can modify user roles");
}
const validRoles = ["player", "moderator", "admin"];
if (!validRoles.includes(request.role)) {
throw new Error("Invalid role");
}
try {
const targetAccount = nk.accountGetId(request.user_id);
const metadata = JSON.parse(targetAccount.user.metadata || "{}");
metadata.role = request.role;
nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata));
logger.info(`User ${request.user_id} role set to ${request.role} by ${ctx.userId}`);
return JSON.stringify({ success: true, role: request.role });
} catch (e) {
logger.error(`Failed to set user role: ${e}`);
throw new Error("Failed to set user role");
}
}
// =============================================================================
// User Profile RPCs
// =============================================================================
function rpcGetUserProfile(
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
payload: string
): string {
const request = JSON.parse(payload || "{}");
const targetUserId = request.user_id || ctx.userId;
try {
const account = nk.accountGetId(targetUserId!);
const metadata = JSON.parse(account.user.metadata || "{}");
// Check if banned
if (metadata.banned && targetUserId === ctx.userId) {
// Check if ban expired
if (metadata.ban_expires) {
const expiresAt = new Date(metadata.ban_expires);
if (expiresAt <= new Date()) {
// Ban expired, remove it
delete metadata.banned;
delete metadata.ban_reason;
delete metadata.ban_expires;
nk.accountUpdateId(targetUserId!, null, null, null, null, null, null, JSON.stringify(metadata));
} else {
throw new Error(`Account banned until ${metadata.ban_expires}. Reason: ${metadata.ban_reason}`);
}
} else {
throw new Error(`Account permanently banned. Reason: ${metadata.ban_reason}`);
}
}
return JSON.stringify({
user_id: account.user.id,
username: account.user.username,
display_name: account.user.displayName,
avatar_url: account.user.avatarUrl,
create_time: account.user.createTime,
role: metadata.role || "player"
});
} catch (e) {
throw e;
}
}
interface UpdateProfileRequest {
display_name?: string;
avatar_url?: string;
}
function rpcUpdateUserProfile(
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
payload: string
): string {
if (!ctx.userId) {
throw new Error("Not authenticated");
}
const request = JSON.parse(payload) as UpdateProfileRequest;
try {
nk.accountUpdateId(
ctx.userId,
null, // username
request.display_name || null,
null, // timezone
null, // location
null, // lang
request.avatar_url || null,
null // metadata
);
return JSON.stringify({ success: true });
} catch (e) {
logger.error(`Failed to update profile: ${e}`);
throw new Error("Failed to update profile");
}
}
// Before login hook to check ban status
function beforeAuthenticateEmail(
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
data: nkruntime.AuthenticateEmailRequest
): nkruntime.AuthenticateEmailRequest {
// Can't check ban before auth, so we check in afterAuthenticate
return data;
}