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:
Claude 2026-04-04 12:12:07 +01:00
parent ad29a45507
commit 3f294340b2
No known key found for this signature in database
GPG key ID: AF404715446AEB41
7 changed files with 203 additions and 56 deletions

View 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);
}
}

View file

@ -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

View 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);
}
}

View 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');
}
}

View file

@ -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);
} }
/** /**

View file

@ -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

View file

@ -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);