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('domain', \App\Http\Middleware\DomainScope::class);
|
||||
app('router')->aliasMiddleware('json.validate', \App\Http\Middleware\ValidateJsonRequest::class);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$name = strtolower(trim($name));
|
||||
$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);
|
||||
$ticket = Actions\UpdateDnsRecords::run($name, $request->input('records', []));
|
||||
|
||||
return response()->json([
|
||||
'name' => $name,
|
||||
'name' => $ticket->name,
|
||||
'ticket' => $ticket->ticket_id,
|
||||
'status' => 'queued',
|
||||
'message' => 'Queued for next block.',
|
||||
], 202);
|
||||
'tx_id' => $ticket->tx_id,
|
||||
'status' => $ticket->status,
|
||||
], $ticket->status === 'queued' ? 202 : 200);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ class Boot extends ServiceProvider
|
|||
// API routes — no CSRF, no session needed
|
||||
$event->routes(fn () => Route::prefix('v1')
|
||||
->withoutMiddleware(ValidateCsrfToken::class)
|
||||
->middleware('json.validate')
|
||||
->group(__DIR__ . '/Routes/api.php'));
|
||||
|
||||
// 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('pool')->group(base_path('app/Mod/Pool/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