feat(cli): move user and guild registration from API to local CLI
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
"description": "Fabric Identity Hub (Center service)",
|
"description": "Fabric Identity Hub (Center service)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"cli": "node dist/cli.js",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"start:dev": "ts-node src/main.ts",
|
"start:dev": "ts-node src/main.ts",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Body, Controller, Get, Headers, Post, UnauthorizedException } from '@nestjs/common';
|
import { Body, Controller, Get, Headers, Post, UnauthorizedException } from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RegisterDto } from './dto.register.dto';
|
|
||||||
import { LoginDto } from './dto.login.dto';
|
import { LoginDto } from './dto.login.dto';
|
||||||
import { RefreshDto } from './dto.refresh.dto';
|
import { RefreshDto } from './dto.refresh.dto';
|
||||||
import { LogoutDto } from './dto.logout.dto';
|
import { LogoutDto } from './dto.logout.dto';
|
||||||
@@ -9,11 +8,6 @@ import { LogoutDto } from './dto.logout.dto';
|
|||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
@Post('register')
|
|
||||||
register(@Body() body: RegisterDto) {
|
|
||||||
return this.authService.register(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
login(@Body() body: LoginDto) {
|
login(@Body() body: LoginDto) {
|
||||||
return this.authService.login(body);
|
return this.authService.login(body);
|
||||||
|
|||||||
60
src/cli.ts
Normal file
60
src/cli.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { AuthService } from './auth/auth.service';
|
||||||
|
import { NodeAdminService } from './nodes/node-admin.service';
|
||||||
|
|
||||||
|
function getArg(flag: string): string | null {
|
||||||
|
const idx = process.argv.indexOf(flag);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
return process.argv[idx + 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsageAndExit(): never {
|
||||||
|
console.error('Usage:');
|
||||||
|
console.error(' node dist/cli.js user create --email <email> --password <password>');
|
||||||
|
console.error(' node dist/cli.js node register --node-id <id> --name <name> --endpoint <url>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [subject, action] = process.argv.slice(2);
|
||||||
|
if (!subject || !action) printUsageAndExit();
|
||||||
|
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
|
||||||
|
try {
|
||||||
|
if (subject === 'user' && action === 'create') {
|
||||||
|
const email = getArg('--email');
|
||||||
|
const password = getArg('--password');
|
||||||
|
if (!email || !password) printUsageAndExit();
|
||||||
|
|
||||||
|
const auth = app.get(AuthService);
|
||||||
|
const user = await auth.register({ email, password });
|
||||||
|
process.stdout.write(JSON.stringify({ ok: true, user }) + '\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subject === 'node' && action === 'register') {
|
||||||
|
const nodeId = getArg('--node-id');
|
||||||
|
const name = getArg('--name');
|
||||||
|
const endpoint = getArg('--endpoint');
|
||||||
|
if (!nodeId || !name || !endpoint) printUsageAndExit();
|
||||||
|
|
||||||
|
const nodes = app.get(NodeAdminService);
|
||||||
|
const result = await nodes.registerNode({ nodeId, name, endpoint });
|
||||||
|
process.stdout.write(JSON.stringify({ ok: true, ...result }) + '\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
printUsageAndExit();
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.message : 'unknown error';
|
||||||
|
process.stderr.write(JSON.stringify({ ok: false, error: message }) + '\n');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -33,11 +33,6 @@ export class CenterApiKeyGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// only guild registration is exempt from API key; it is protected by HMAC secret
|
|
||||||
if (method === 'POST' && (path === '/nodes/register' || path.endsWith('/nodes/register'))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const received = req.headers['x-api-key'];
|
const received = req.headers['x-api-key'];
|
||||||
const apiKey = Array.isArray(received) ? received[0] : received;
|
const apiKey = Array.isArray(received) ? received[0] : received;
|
||||||
if (!apiKey) throw new UnauthorizedException('missing api key');
|
if (!apiKey) throw new UnauthorizedException('missing api key');
|
||||||
|
|||||||
59
src/nodes/node-admin.service.ts
Normal file
59
src/nodes/node-admin.service.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { ConflictException, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { GuildNode } from '../entities/guild-node.entity';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NodeAdminService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(GuildNode)
|
||||||
|
private readonly nodeRepo: Repository<GuildNode>,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async registerNode(input: { nodeId: string; name: string; endpoint: string }) {
|
||||||
|
const existedByNodeId = await this.nodeRepo.findOne({ where: { nodeId: input.nodeId } });
|
||||||
|
if (existedByNodeId) {
|
||||||
|
throw new ConflictException('nodeId already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existedByEndpoint = await this.nodeRepo.findOne({ where: { endpoint: input.endpoint } });
|
||||||
|
if (existedByEndpoint) {
|
||||||
|
throw new ConflictException('endpoint already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = this.nodeRepo.create({
|
||||||
|
nodeId: input.nodeId,
|
||||||
|
name: input.name,
|
||||||
|
endpoint: input.endpoint,
|
||||||
|
status: 'active',
|
||||||
|
apiKeyHash: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawApiKey = `gk_${randomBytes(24).toString('hex')}`;
|
||||||
|
node.apiKeyHash = await bcrypt.hash(rawApiKey, 10);
|
||||||
|
const saved = await this.nodeRepo.save(node);
|
||||||
|
|
||||||
|
await this.audit.write({
|
||||||
|
action: 'node.register',
|
||||||
|
targetType: 'node',
|
||||||
|
targetId: saved.nodeId,
|
||||||
|
detail: JSON.stringify({ endpoint: saved.endpoint, via: 'cli' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: {
|
||||||
|
id: saved.id,
|
||||||
|
nodeId: saved.nodeId,
|
||||||
|
name: saved.name,
|
||||||
|
endpoint: saved.endpoint,
|
||||||
|
status: saved.status,
|
||||||
|
},
|
||||||
|
apiKey: rawApiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,29 +1,20 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
ConflictException,
|
|
||||||
Controller,
|
Controller,
|
||||||
DefaultValuePipe,
|
DefaultValuePipe,
|
||||||
ForbiddenException,
|
|
||||||
Get,
|
Get,
|
||||||
Headers,
|
|
||||||
HttpException,
|
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import * as bcrypt from 'bcryptjs';
|
|
||||||
import { randomBytes } from 'crypto';
|
|
||||||
import { GuildNode } from '../entities/guild-node.entity';
|
import { GuildNode } from '../entities/guild-node.entity';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { RegisterNodeDto } from './dto.register-node.dto';
|
|
||||||
import { UpdateNodeStatusDto } from './dto.update-node-status.dto';
|
import { UpdateNodeStatusDto } from './dto.update-node-status.dto';
|
||||||
import { FABRIC_PROTOCOL_VERSION, normalizeVersion } from '../common/version';
|
|
||||||
|
|
||||||
@Controller('nodes')
|
@Controller('nodes')
|
||||||
export class NodesController {
|
export class NodesController {
|
||||||
@@ -33,81 +24,6 @@ export class NodesController {
|
|||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('register')
|
|
||||||
async register(
|
|
||||||
@Req() req: { ip?: string; socket?: { remoteAddress?: string } },
|
|
||||||
@Body() body: RegisterNodeDto,
|
|
||||||
@Headers('x-fabric-version') fabricVersion?: string,
|
|
||||||
) {
|
|
||||||
const remoteAddress = (req.ip ?? req.socket?.remoteAddress ?? '').toLowerCase();
|
|
||||||
const isLoopback =
|
|
||||||
remoteAddress === '127.0.0.1' ||
|
|
||||||
remoteAddress === '::1' ||
|
|
||||||
remoteAddress === '::ffff:127.0.0.1';
|
|
||||||
if (!isLoopback) {
|
|
||||||
throw new ForbiddenException('register endpoint only allows localhost caller');
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestedVersion = normalizeVersion(fabricVersion);
|
|
||||||
if (requestedVersion !== FABRIC_PROTOCOL_VERSION) {
|
|
||||||
throw new HttpException(
|
|
||||||
{
|
|
||||||
error: {
|
|
||||||
code: 'FABRIC_VERSION_NOT_SUPPORTED',
|
|
||||||
message: `unsupported protocol version: ${requestedVersion}`,
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
supportedVersion: FABRIC_PROTOCOL_VERSION,
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existedByNodeId = await this.nodeRepo.findOne({
|
|
||||||
where: { nodeId: body.nodeId },
|
|
||||||
});
|
|
||||||
if (existedByNodeId) {
|
|
||||||
throw new ConflictException('nodeId already exists');
|
|
||||||
}
|
|
||||||
|
|
||||||
const existedByEndpoint = await this.nodeRepo.findOne({
|
|
||||||
where: { endpoint: body.endpoint },
|
|
||||||
});
|
|
||||||
if (existedByEndpoint) {
|
|
||||||
throw new ConflictException('endpoint already exists');
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = this.nodeRepo.create({
|
|
||||||
nodeId: body.nodeId,
|
|
||||||
name: body.name,
|
|
||||||
endpoint: body.endpoint,
|
|
||||||
status: 'active',
|
|
||||||
apiKeyHash: null,
|
|
||||||
});
|
|
||||||
const rawApiKey = `gk_${randomBytes(24).toString('hex')}`;
|
|
||||||
node.apiKeyHash = await bcrypt.hash(rawApiKey, 10);
|
|
||||||
const saved = await this.nodeRepo.save(node);
|
|
||||||
await this.audit.write({
|
|
||||||
action: 'node.register',
|
|
||||||
targetType: 'node',
|
|
||||||
targetId: saved.nodeId,
|
|
||||||
detail: JSON.stringify({ endpoint: saved.endpoint }),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'accepted',
|
|
||||||
negotiatedVersion: FABRIC_PROTOCOL_VERSION,
|
|
||||||
node: {
|
|
||||||
id: saved.id,
|
|
||||||
nodeId: saved.nodeId,
|
|
||||||
name: saved.name,
|
|
||||||
endpoint: saved.endpoint,
|
|
||||||
status: saved.status,
|
|
||||||
},
|
|
||||||
apiKey: rawApiKey,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':nodeId/heartbeat')
|
@Post(':nodeId/heartbeat')
|
||||||
async heartbeat(@Param('nodeId') nodeId: string) {
|
async heartbeat(@Param('nodeId') nodeId: string) {
|
||||||
const node = await this.nodeRepo.findOne({ where: { nodeId } });
|
const node = await this.nodeRepo.findOne({ where: { nodeId } });
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { NodesController } from './nodes.controller';
|
import { NodesController } from './nodes.controller';
|
||||||
import { GuildNode } from '../entities/guild-node.entity';
|
import { GuildNode } from '../entities/guild-node.entity';
|
||||||
|
import { NodeAdminService } from './node-admin.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([GuildNode])],
|
imports: [TypeOrmModule.forFeature([GuildNode])],
|
||||||
controllers: [NodesController],
|
controllers: [NodesController],
|
||||||
|
providers: [NodeAdminService],
|
||||||
|
exports: [NodeAdminService],
|
||||||
})
|
})
|
||||||
export class NodesModule {}
|
export class NodesModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user