diff --git a/app/Http/Middleware/ValidateJsonRequest.php b/app/Http/Middleware/ValidateJsonRequest.php new file mode 100644 index 0000000..d102fee --- /dev/null +++ b/app/Http/Middleware/ValidateJsonRequest.php @@ -0,0 +1,39 @@ +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); + } +} diff --git a/app/Mod/Chain/Boot.php b/app/Mod/Chain/Boot.php index 55b2a5a..96cf0e2 100644 --- a/app/Mod/Chain/Boot.php +++ b/app/Mod/Chain/Boot.php @@ -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 diff --git a/app/Mod/Names/Actions/UpdateDnsRecords.php b/app/Mod/Names/Actions/UpdateDnsRecords.php new file mode 100644 index 0000000..59a39e2 --- /dev/null +++ b/app/Mod/Names/Actions/UpdateDnsRecords.php @@ -0,0 +1,91 @@ + '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); + } +} diff --git a/app/Mod/Names/Controllers/MetricsController.php b/app/Mod/Names/Controllers/MetricsController.php new file mode 100644 index 0000000..350fb7f --- /dev/null +++ b/app/Mod/Names/Controllers/MetricsController.php @@ -0,0 +1,63 @@ +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'); + } +} diff --git a/app/Mod/Names/Controllers/NamesController.php b/app/Mod/Names/Controllers/NamesController.php index 118ec8f..ddee12e 100644 --- a/app/Mod/Names/Controllers/NamesController.php +++ b/app/Mod/Names/Controllers/NamesController.php @@ -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); } /** diff --git a/app/Website/Api/Boot.php b/app/Website/Api/Boot.php index 09742f6..bb41ccc 100644 --- a/app/Website/Api/Boot.php +++ b/app/Website/Api/Boot.php @@ -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 diff --git a/app/Website/Api/Routes/api.php b/app/Website/Api/Routes/api.php index 2967fd4..ee36572 100644 --- a/app/Website/Api/Routes/api.php +++ b/app/Website/Api/Routes/api.php @@ -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);