feat: UpdateDnsRecords Action, Prometheus metrics, JSON validation
- UpdateDnsRecords Action: controller method now one-liner, all DNS logic in Action with activity logging and edit lock. - Prometheus metrics at /v1/metrics: chain_height, alias_count, claims_pending, dns_tickets, gateways_live. Grafana-ready. - ValidateJsonRequest middleware: enforces application/json on POST, 64KB body size limit. Applied to all /v1/* API routes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ad29a45507
commit
3f294340b2
7 changed files with 203 additions and 56 deletions
39
app/Http/Middleware/ValidateJsonRequest.php
Normal file
39
app/Http/Middleware/ValidateJsonRequest.php
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates API request body — JSON content type and size limit.
|
||||||
|
*
|
||||||
|
* Applied to POST/PUT/PATCH API routes to prevent abuse.
|
||||||
|
*/
|
||||||
|
class ValidateJsonRequest
|
||||||
|
{
|
||||||
|
private const MAX_BODY_SIZE = 65536; // 64KB
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): mixed
|
||||||
|
{
|
||||||
|
if (in_array($request->method(), ['POST', 'PUT', 'PATCH'])) {
|
||||||
|
$contentType = $request->header('Content-Type', '');
|
||||||
|
|
||||||
|
if (! str_contains($contentType, 'application/json')) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Content-Type must be application/json.',
|
||||||
|
], 415);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($request->getContent()) > self::MAX_BODY_SIZE) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Request body too large. Maximum 64KB.',
|
||||||
|
], 413);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ class Boot
|
||||||
|
|
||||||
app('router')->aliasMiddleware('auth.api', \App\Http\Middleware\ApiTokenAuth::class);
|
app('router')->aliasMiddleware('auth.api', \App\Http\Middleware\ApiTokenAuth::class);
|
||||||
app('router')->aliasMiddleware('domain', \App\Http\Middleware\DomainScope::class);
|
app('router')->aliasMiddleware('domain', \App\Http\Middleware\DomainScope::class);
|
||||||
|
app('router')->aliasMiddleware('json.validate', \App\Http\Middleware\ValidateJsonRequest::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onConsole(ConsoleBooting $event): void
|
public function onConsole(ConsoleBooting $event): void
|
||||||
|
|
|
||||||
91
app/Mod/Names/Actions/UpdateDnsRecords.php
Normal file
91
app/Mod/Names/Actions/UpdateDnsRecords.php
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Names\Actions;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Mod\Chain\Services\DaemonRpc;
|
||||||
|
use Mod\Chain\Services\WalletRpc;
|
||||||
|
use Mod\Names\Models\DnsTicket;
|
||||||
|
use Mod\Names\Models\NameActivity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update DNS records for a .lthn name on the sidechain.
|
||||||
|
*
|
||||||
|
* $ticket = UpdateDnsRecords::run('mybrand', [
|
||||||
|
* ['type' => 'A', 'host' => '@', 'value' => '1.2.3.4'],
|
||||||
|
* ]);
|
||||||
|
*/
|
||||||
|
class UpdateDnsRecords
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DaemonRpc $rpc,
|
||||||
|
private readonly WalletRpc $wallet,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(string $name, array $records): DnsTicket
|
||||||
|
{
|
||||||
|
$name = strtolower(trim($name));
|
||||||
|
|
||||||
|
// Edit lock
|
||||||
|
$editLock = "dns_edit_lock:{$name}";
|
||||||
|
if (Cache::has($editLock)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'name' => 'A DNS update for this name is already pending.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$alias = $this->rpc->getAliasByName($name);
|
||||||
|
if (! $alias) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'name' => 'Name not registered.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = $alias['address'] ?? '';
|
||||||
|
$comment = $this->buildComment($records);
|
||||||
|
|
||||||
|
$result = $this->wallet->updateAlias($name, $address, $comment);
|
||||||
|
|
||||||
|
if (isset($result['tx_id'])) {
|
||||||
|
Cache::put($editLock, true, 300);
|
||||||
|
$ticket = DnsTicket::open($name, $records, $result['tx_id'], $address, $comment);
|
||||||
|
} else {
|
||||||
|
$ticket = DnsTicket::open($name, $records, null, $address, $comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
NameActivity::log($name, 'dns_updated', [
|
||||||
|
'ticket_id' => $ticket->ticket_id,
|
||||||
|
'records' => $records,
|
||||||
|
], request()?->ip());
|
||||||
|
|
||||||
|
return $ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildComment(array $records): string
|
||||||
|
{
|
||||||
|
$dnsEntries = [];
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$type = $record['type'] ?? 'A';
|
||||||
|
$host = $record['host'] ?? '@';
|
||||||
|
$value = $record['value'] ?? '';
|
||||||
|
if ($value) {
|
||||||
|
$dnsEntries[] = "{$type}:{$host}:{$value}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$comment = 'v=lthn1;type=user';
|
||||||
|
if ($dnsEntries) {
|
||||||
|
$comment .= ';dns=' . implode('|', $dnsEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function run(string $name, array $records): DnsTicket
|
||||||
|
{
|
||||||
|
return app(static::class)->handle($name, $records);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Mod/Names/Controllers/MetricsController.php
Normal file
63
app/Mod/Names/Controllers/MetricsController.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Names\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
use Mod\Chain\Services\DaemonRpc;
|
||||||
|
use Mod\Gateway\Services\GatewayRegistry;
|
||||||
|
use Mod\Names\Models\DnsTicket;
|
||||||
|
use Mod\Names\Models\NameClaim;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prometheus metrics endpoint.
|
||||||
|
*
|
||||||
|
* GET /v1/metrics
|
||||||
|
*
|
||||||
|
* Returns Prometheus text exposition format for Grafana scraping.
|
||||||
|
*/
|
||||||
|
class MetricsController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(DaemonRpc $rpc, GatewayRegistry $registry): \Illuminate\Http\Response
|
||||||
|
{
|
||||||
|
$info = $rpc->getInfo();
|
||||||
|
$offline = isset($info['_offline']);
|
||||||
|
|
||||||
|
$lines = [];
|
||||||
|
$lines[] = '# HELP lthn_chain_height Current blockchain height';
|
||||||
|
$lines[] = '# TYPE lthn_chain_height gauge';
|
||||||
|
$lines[] = 'lthn_chain_height ' . ($info['height'] ?? 0);
|
||||||
|
|
||||||
|
$lines[] = '# HELP lthn_alias_count Total registered aliases';
|
||||||
|
$lines[] = '# TYPE lthn_alias_count gauge';
|
||||||
|
$lines[] = 'lthn_alias_count ' . ($info['alias_count'] ?? 0);
|
||||||
|
|
||||||
|
$lines[] = '# HELP lthn_tx_pool_size Pending transactions';
|
||||||
|
$lines[] = '# TYPE lthn_tx_pool_size gauge';
|
||||||
|
$lines[] = 'lthn_tx_pool_size ' . ($info['tx_pool_size'] ?? 0);
|
||||||
|
|
||||||
|
$lines[] = '# HELP lthn_daemon_online Daemon reachability';
|
||||||
|
$lines[] = '# TYPE lthn_daemon_online gauge';
|
||||||
|
$lines[] = 'lthn_daemon_online ' . ($offline ? 0 : 1);
|
||||||
|
|
||||||
|
$lines[] = '# HELP lthn_claims_pending Pending name claims';
|
||||||
|
$lines[] = '# TYPE lthn_claims_pending gauge';
|
||||||
|
$lines[] = 'lthn_claims_pending ' . NameClaim::pending()->count();
|
||||||
|
|
||||||
|
$lines[] = '# HELP lthn_claims_total Total name claims';
|
||||||
|
$lines[] = '# TYPE lthn_claims_total counter';
|
||||||
|
$lines[] = 'lthn_claims_total ' . NameClaim::count();
|
||||||
|
|
||||||
|
$lines[] = '# HELP lthn_dns_tickets_pending Pending DNS tickets';
|
||||||
|
$lines[] = '# TYPE lthn_dns_tickets_pending gauge';
|
||||||
|
$lines[] = 'lthn_dns_tickets_pending ' . DnsTicket::pending()->count();
|
||||||
|
|
||||||
|
$lines[] = '# HELP lthn_gateways_live Live paired gateways';
|
||||||
|
$lines[] = '# TYPE lthn_gateways_live gauge';
|
||||||
|
$lines[] = 'lthn_gateways_live ' . count($registry->liveGateways());
|
||||||
|
|
||||||
|
return response(implode("\n", $lines) . "\n", 200)
|
||||||
|
->header('Content-Type', 'text/plain; charset=utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -242,65 +242,14 @@ class NamesController extends Controller
|
||||||
*/
|
*/
|
||||||
public function updateRecords(Request $request, string $name): JsonResponse
|
public function updateRecords(Request $request, string $name): JsonResponse
|
||||||
{
|
{
|
||||||
$name = strtolower(trim($name));
|
$ticket = Actions\UpdateDnsRecords::run($name, $request->input('records', []));
|
||||||
$records = $request->input('records', []);
|
|
||||||
|
|
||||||
// Per-name lock prevents concurrent edits
|
|
||||||
$editLock = "dns_edit_lock:{$name}";
|
|
||||||
if (Cache::has($editLock)) {
|
|
||||||
return response()->json([
|
|
||||||
'error' => 'A DNS update for this name is already pending.',
|
|
||||||
'name' => $name,
|
|
||||||
], 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
$alias = $this->rpc->getAliasByName($name);
|
|
||||||
if (! $alias) {
|
|
||||||
return response()->json(['error' => 'Name not registered'], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$address = $alias['address'] ?? '';
|
|
||||||
|
|
||||||
// Build dns= comment
|
|
||||||
$dnsEntries = [];
|
|
||||||
foreach ($records as $record) {
|
|
||||||
$type = $record['type'] ?? 'A';
|
|
||||||
$host = $record['host'] ?? '@';
|
|
||||||
$value = $record['value'] ?? '';
|
|
||||||
if ($value) {
|
|
||||||
$dnsEntries[] = "{$type}:{$host}:{$value}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$comment = 'v=lthn1;type=user';
|
|
||||||
if ($dnsEntries) {
|
|
||||||
$comment .= ';dns=' . implode('|', $dnsEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $this->wallet->updateAlias($name, $address, $comment);
|
|
||||||
|
|
||||||
if (isset($result['tx_id'])) {
|
|
||||||
Cache::put($editLock, true, 300);
|
|
||||||
$ticket = Models\DnsTicket::open($name, $records, $result['tx_id'], $address, $comment);
|
|
||||||
Models\NameActivity::log($name, 'dns_updated', ['ticket_id' => $ticket->ticket_id, 'records' => $records], request()?->ip());
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'name' => $name,
|
|
||||||
'ticket' => $ticket->ticket_id,
|
|
||||||
'tx_id' => $result['tx_id'],
|
|
||||||
'status' => 'pending',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chain busy — queue for retry
|
|
||||||
$ticket = Models\DnsTicket::open($name, $records, null, $address, $comment);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'name' => $name,
|
'name' => $ticket->name,
|
||||||
'ticket' => $ticket->ticket_id,
|
'ticket' => $ticket->ticket_id,
|
||||||
'status' => 'queued',
|
'tx_id' => $ticket->tx_id,
|
||||||
'message' => 'Queued for next block.',
|
'status' => $ticket->status,
|
||||||
], 202);
|
], $ticket->status === 'queued' ? 202 : 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ class Boot extends ServiceProvider
|
||||||
// API routes — no CSRF, no session needed
|
// API routes — no CSRF, no session needed
|
||||||
$event->routes(fn () => Route::prefix('v1')
|
$event->routes(fn () => Route::prefix('v1')
|
||||||
->withoutMiddleware(ValidateCsrfToken::class)
|
->withoutMiddleware(ValidateCsrfToken::class)
|
||||||
|
->middleware('json.validate')
|
||||||
->group(__DIR__ . '/Routes/api.php'));
|
->group(__DIR__ . '/Routes/api.php'));
|
||||||
|
|
||||||
// Homepage scoped to API domain via middleware
|
// Homepage scoped to API domain via middleware
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,6 @@ Route::prefix('proxy')->group(base_path('app/Mod/Proxy/Routes/api.php'));
|
||||||
Route::prefix('gateway')->group(base_path('app/Mod/Gateway/Routes/api.php'));
|
Route::prefix('gateway')->group(base_path('app/Mod/Gateway/Routes/api.php'));
|
||||||
Route::prefix('pool')->group(base_path('app/Mod/Pool/Routes/api.php'));
|
Route::prefix('pool')->group(base_path('app/Mod/Pool/Routes/api.php'));
|
||||||
Route::prefix('trade')->group(base_path('app/Mod/Trade/Routes/api.php'));
|
Route::prefix('trade')->group(base_path('app/Mod/Trade/Routes/api.php'));
|
||||||
|
|
||||||
|
// Prometheus metrics
|
||||||
|
Route::get('metrics', \Mod\Names\Controllers\MetricsController::class);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue