- 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>
539 lines
18 KiB
PHP
539 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Mod\Names\Controllers;
|
|
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Routing\Controller;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Mod\Chain\Services\DaemonRpc;
|
|
use Mod\Chain\Services\WalletRpc;
|
|
use Mod\Names\Actions;
|
|
use Mod\Names\Models;
|
|
use Mod\Names\Models\NameClaim;
|
|
use Mod\Names\Resources;
|
|
|
|
/**
|
|
* .lthn TLD registrar API.
|
|
*
|
|
* GET /v1/names/available/{name} — check if name is available
|
|
* GET /v1/names/lookup/{name} — look up a registered name
|
|
* GET /v1/names/search?q={query} — search names
|
|
* POST /v1/names/register — request name registration
|
|
* GET /v1/names/directory — list all names grouped by type
|
|
*/
|
|
class NamesController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly DaemonRpc $rpc,
|
|
private readonly WalletRpc $wallet,
|
|
) {}
|
|
|
|
/**
|
|
* GET /v1/names/available/myname
|
|
*/
|
|
public function available(string $name): JsonResponse
|
|
{
|
|
return response()->json(Actions\CheckAvailability::run($name));
|
|
}
|
|
|
|
/**
|
|
* GET /v1/names/lookup/charon
|
|
*/
|
|
public function lookup(string $name): JsonResponse
|
|
{
|
|
$alias = $this->rpc->getAliasByName(strtolower(trim($name)));
|
|
|
|
if (! $alias) {
|
|
return response()->json(['error' => 'Name not registered'], 404);
|
|
}
|
|
|
|
$alias['name'] = $name;
|
|
|
|
return (new Resources\NameResource($alias))->response();
|
|
}
|
|
|
|
/**
|
|
* GET /v1/names/search?q=gate
|
|
*/
|
|
public function search(Request $request): JsonResponse
|
|
{
|
|
$query = strtolower(trim((string) $request->get('q')));
|
|
$result = $this->rpc->getAllAliases();
|
|
$aliases = $result['aliases'] ?? [];
|
|
|
|
$matches = array_filter($aliases, function ($alias) use ($query) {
|
|
return str_contains($alias['alias'] ?? '', $query)
|
|
|| str_contains($alias['comment'] ?? '', $query);
|
|
});
|
|
|
|
return response()->json([
|
|
'query' => $query,
|
|
'results' => array_values($matches),
|
|
'count' => count($matches),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* GET /v1/names/directory
|
|
*/
|
|
public function directory(): JsonResponse
|
|
{
|
|
$result = $this->rpc->getAllAliases();
|
|
$aliases = $result['aliases'] ?? [];
|
|
|
|
// Group by type from comment metadata
|
|
$grouped = ['gateway' => [], 'service' => [], 'exit' => [], 'reserved' => [], 'user' => []];
|
|
|
|
foreach ($aliases as $alias) {
|
|
$comment = $alias['comment'] ?? '';
|
|
if (str_contains($comment, 'type=gateway')) {
|
|
$grouped['gateway'][] = $alias;
|
|
} elseif (str_contains($comment, 'type=exit')) {
|
|
$grouped['exit'][] = $alias;
|
|
} elseif (str_contains($comment, 'type=service')) {
|
|
$grouped['service'][] = $alias;
|
|
} elseif (str_contains($comment, 'type=reserved')) {
|
|
$grouped['reserved'][] = $alias;
|
|
} else {
|
|
$grouped['user'][] = $alias;
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'total' => count($aliases),
|
|
'directory' => $grouped,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* POST /v1/names/register {"name": "mysite", "address": "iTHN..."}
|
|
*/
|
|
public function register(Request $request): JsonResponse
|
|
{
|
|
$name = strtolower(trim((string) $request->input('name')));
|
|
$address = trim((string) $request->input('address'));
|
|
$comment = trim((string) $request->input('comment', 'v=lthn1;type=user'));
|
|
|
|
if (! $this->isValidName($name)) {
|
|
return response()->json([
|
|
'error' => 'Invalid name. Use 1-63 lowercase alphanumeric characters.',
|
|
], 422);
|
|
}
|
|
|
|
if (empty($address) || ! str_starts_with($address, 'iTHN')) {
|
|
return response()->json([
|
|
'error' => 'Invalid Lethean address.',
|
|
], 422);
|
|
}
|
|
|
|
// Pre-flight: check wallet has funds
|
|
$balance = $this->wallet->getBalance();
|
|
$unlocked = ($balance['unlocked_balance'] ?? 0) / 1e12;
|
|
if ($unlocked < 0.01) {
|
|
return response()->json([
|
|
'error' => 'Registrar wallet has insufficient funds. Please try again later.',
|
|
'name' => $name,
|
|
], 503);
|
|
}
|
|
|
|
// Check availability on chain + reservation lock
|
|
$existing = $this->rpc->getAliasByName($name);
|
|
if ($existing !== null) {
|
|
return response()->json([
|
|
'error' => 'Name already registered.',
|
|
'name' => $name,
|
|
], 409);
|
|
}
|
|
|
|
// Atomic reservation — prevent race condition
|
|
$lockKey = "name_lock:{$name}";
|
|
if (! Cache::add($lockKey, true, 600)) {
|
|
return response()->json([
|
|
'error' => 'This name is being registered by another customer. Please try a different name.',
|
|
'name' => $name,
|
|
], 409);
|
|
}
|
|
|
|
// Register via wallet RPC
|
|
$result = $this->wallet->registerAlias($name, $address, $comment);
|
|
|
|
if (isset($result['code']) || isset($result['error'])) {
|
|
$message = $result['message'] ?? ($result['error'] ?? 'Unknown error');
|
|
$code = 502;
|
|
|
|
if (str_contains($message, 'NOT_ENOUGH_MONEY')) {
|
|
$message = 'Registrar wallet has insufficient funds. Please try again later.';
|
|
$code = 503;
|
|
}
|
|
|
|
// Release lock on permanent failure so name can be retried
|
|
Cache::forget($lockKey);
|
|
|
|
return response()->json([
|
|
'error' => $message,
|
|
'name' => $name,
|
|
], $code);
|
|
}
|
|
|
|
if (isset($result['tx_id'])) {
|
|
return response()->json([
|
|
'name' => $name,
|
|
'fqdn' => "{$name}.lthn",
|
|
'address' => $address,
|
|
'tx_id' => $result['tx_id'],
|
|
'status' => 'pending',
|
|
], 201);
|
|
}
|
|
|
|
return response()->json([
|
|
'error' => 'Unexpected response from chain',
|
|
'details' => $result,
|
|
], 500);
|
|
}
|
|
|
|
/**
|
|
* GET /v1/names/records/charon
|
|
*
|
|
* Reads DNS records from the LNS sidechain.
|
|
*/
|
|
public function records(string $name): JsonResponse
|
|
{
|
|
$name = strtolower(trim($name));
|
|
$lnsUrl = config('chain.lns_url', 'http://127.0.0.1:5553');
|
|
|
|
$records = [];
|
|
foreach (['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SRV'] as $type) {
|
|
try {
|
|
$response = Http::timeout(3)->get("{$lnsUrl}/resolve", ['name' => $name, 'type' => $type]);
|
|
$data = $response->successful() ? $response->json() : null;
|
|
} catch (\Throwable) {
|
|
$data = null;
|
|
}
|
|
if ($data) {
|
|
if (! empty($data[$type])) {
|
|
foreach ($data[$type] as $value) {
|
|
$records[] = [
|
|
'type' => $type,
|
|
'host' => '@',
|
|
'value' => $value,
|
|
'ttl' => 3600,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'name' => $name,
|
|
'fqdn' => "{$name}.lthn",
|
|
'records' => $records,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* POST /v1/names/records/charon {"records": [{"type":"A","host":"@","value":"1.2.3.4"}]}
|
|
*
|
|
* Updates DNS records by calling update_alias on the wallet RPC.
|
|
* Encodes records into the alias comment field for LNS to parse.
|
|
*/
|
|
public function updateRecords(Request $request, string $name): JsonResponse
|
|
{
|
|
$ticket = Actions\UpdateDnsRecords::run($name, $request->input('records', []));
|
|
|
|
return response()->json([
|
|
'name' => $ticket->name,
|
|
'ticket' => $ticket->ticket_id,
|
|
'tx_id' => $ticket->tx_id,
|
|
'status' => $ticket->status,
|
|
], $ticket->status === 'queued' ? 202 : 200);
|
|
}
|
|
|
|
/**
|
|
* GET /v1/names/ticket/{id}
|
|
*
|
|
* Check the status of a DNS change ticket.
|
|
*/
|
|
public function ticket(string $id): JsonResponse
|
|
{
|
|
$ticket = Models\DnsTicket::where('ticket_id', $id)->first();
|
|
|
|
if (! $ticket) {
|
|
return response()->json(['error' => 'Ticket not found'], 404);
|
|
}
|
|
|
|
// If pending, check if the tx has confirmed on chain
|
|
if ($ticket->status === 'pending' && $ticket->tx_id) {
|
|
$alias = $this->rpc->getAliasByName($ticket->name);
|
|
if ($alias && str_contains($alias['comment'] ?? '', 'dns=')) {
|
|
$ticket->confirm();
|
|
Cache::forget("dns_edit_lock:{$ticket->name}");
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'ticket' => $ticket->ticket_id,
|
|
'name' => $ticket->name,
|
|
'status' => $ticket->status,
|
|
'tx_id' => $ticket->tx_id,
|
|
'created_at' => $ticket->created_at?->toIso8601String(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* GET /v1/names/health
|
|
*
|
|
* Registrar health — wallet balance, chain status, readiness.
|
|
*/
|
|
public function health(): JsonResponse
|
|
{
|
|
$balance = $this->wallet->getBalance();
|
|
$info = $this->rpc->getInfo();
|
|
|
|
$walletOffline = isset($balance['error']);
|
|
$daemonOffline = isset($info['_offline']);
|
|
$daemonStale = isset($info['_stale']);
|
|
$unlocked = ($balance['unlocked_balance'] ?? 0) / 1e12;
|
|
$fee = 0.01;
|
|
|
|
$registrationsRemaining = (int) floor($unlocked / $fee);
|
|
$lowFunds = $registrationsRemaining < 10;
|
|
$criticalFunds = $registrationsRemaining < 2;
|
|
|
|
$status = 'healthy';
|
|
if ($daemonOffline || $walletOffline) {
|
|
$status = 'offline';
|
|
} elseif ($criticalFunds) {
|
|
$status = 'critical';
|
|
} elseif ($lowFunds) {
|
|
$status = 'low_funds';
|
|
} elseif ($daemonStale) {
|
|
$status = 'degraded';
|
|
}
|
|
|
|
$httpCode = match ($status) {
|
|
'offline' => 503,
|
|
'critical' => 503,
|
|
'degraded' => 200,
|
|
default => 200,
|
|
};
|
|
|
|
return response()->json([
|
|
'status' => $status,
|
|
'registrar' => [
|
|
'balance' => round($unlocked, 4),
|
|
'registrations_remaining' => $registrationsRemaining,
|
|
'low_funds' => $lowFunds,
|
|
'wallet_online' => ! $walletOffline,
|
|
],
|
|
'chain' => [
|
|
'height' => $info['height'] ?? 0,
|
|
'aliases' => $info['alias_count'] ?? 0,
|
|
'pool_size' => $info['tx_pool_size'] ?? 0,
|
|
'synced' => ($info['daemon_network_state'] ?? 0) == 2,
|
|
'daemon_online' => ! $daemonOffline,
|
|
'stale' => $daemonStale,
|
|
],
|
|
], $httpCode);
|
|
}
|
|
|
|
/**
|
|
* GET /v1/names/sunrise/verify/{name}
|
|
*
|
|
* Generate a verification token for a sunrise claim.
|
|
* The brand adds this as a DNS TXT record to prove domain ownership.
|
|
*/
|
|
public function sunriseVerify(string $name): JsonResponse
|
|
{
|
|
$name = strtolower(trim($name));
|
|
|
|
// Check name is reserved
|
|
$alias = $this->rpc->getAliasByName($name);
|
|
if (! $alias || ! str_contains($alias['comment'] ?? '', 'type=reserved')) {
|
|
return response()->json(['error' => 'Name is not in the sunrise reservation list'], 404);
|
|
}
|
|
|
|
// Generate a deterministic verification token
|
|
$token = 'lthn-verify=' . substr(hash('sha256', $name . config('chain.api_token', 'lthn')), 0, 32);
|
|
|
|
return response()->json([
|
|
'name' => $name,
|
|
'fqdn' => "{$name}.lthn",
|
|
'verification' => [
|
|
'method' => 'dns-txt',
|
|
'instruction' => "Add a TXT record to your domain's DNS to prove ownership",
|
|
'record_host' => "_lthn-verify.{$name}.com",
|
|
'record_type' => 'TXT',
|
|
'record_value' => $token,
|
|
'example' => "_lthn-verify.{$name}.com. IN TXT \"{$token}\"",
|
|
'check_url' => "/v1/names/sunrise/check/{$name}",
|
|
],
|
|
'alternative_domains' => [
|
|
"{$name}.com",
|
|
"{$name}.org",
|
|
"{$name}.net",
|
|
"{$name}.io",
|
|
"{$name}.co.uk",
|
|
],
|
|
'claim_process' => [
|
|
'step_1' => 'Add DNS TXT record to verify domain ownership',
|
|
'step_2' => 'Call check endpoint to confirm verification',
|
|
'step_3' => 'Purchase the name via https://order.lthn.ai',
|
|
'step_4' => 'Name transferred to your wallet with full DNS control',
|
|
],
|
|
'ownership_tiers' => [
|
|
'free' => 'Registry holds private key. Limited DNS records. No wallet transfer.',
|
|
'paid' => 'Your wallet, your key. Expanded DNS record limits. Full sovereignty.',
|
|
],
|
|
'purchase_url' => 'https://order.lthn.ai/order/',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* GET /v1/names/sunrise/check/{name}
|
|
*
|
|
* Check if a sunrise verification TXT record has been added.
|
|
* Looks up _lthn-verify.{name}.com for the expected token.
|
|
*/
|
|
public function sunriseCheck(string $name): JsonResponse
|
|
{
|
|
$name = strtolower(trim($name));
|
|
$expectedToken = 'lthn-verify=' . substr(hash('sha256', $name . config('chain.api_token', 'lthn')), 0, 32);
|
|
|
|
$verified = false;
|
|
$checkedDomains = [];
|
|
|
|
foreach (['.com', '.org', '.net', '.io', '.co.uk'] as $tld) {
|
|
$host = "_lthn-verify.{$name}{$tld}";
|
|
$records = @dns_get_record($host, DNS_TXT) ?: [];
|
|
|
|
$found = false;
|
|
foreach ($records as $record) {
|
|
if (($record['txt'] ?? '') === $expectedToken) {
|
|
$found = true;
|
|
$verified = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
$checkedDomains[] = [
|
|
'domain' => "{$name}{$tld}",
|
|
'host' => $host,
|
|
'verified' => $found,
|
|
];
|
|
}
|
|
|
|
return response()->json([
|
|
'name' => $name,
|
|
'fqdn' => "{$name}.lthn",
|
|
'verified' => $verified,
|
|
'expected_token' => $expectedToken,
|
|
'checked_domains' => $checkedDomains,
|
|
'status' => $verified ? 'verified' : 'pending',
|
|
'next_steps' => $verified
|
|
? 'Domain ownership verified. Complete your claim by purchasing at https://order.lthn.ai — your name will be transferred to your wallet with full DNS control.'
|
|
: 'TXT record not found. Add the record and allow DNS propagation (up to 48h).',
|
|
'purchase_url' => $verified ? 'https://order.lthn.ai/order/' : null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* POST /v1/names/claim {"name": "mysite", "email": "user@example.com"}
|
|
*
|
|
* Pre-register a name claim. Queued for manual approval during soft launch.
|
|
*/
|
|
public function claim(Request $request): JsonResponse
|
|
{
|
|
$claim = Actions\SubmitClaim::run($request->only(['name', 'email']));
|
|
|
|
return (new Resources\ClaimResource($claim))
|
|
->additional(['message' => "Your claim has been submitted. We will notify you at {$claim->email} when approved."])
|
|
->response()
|
|
->setStatusCode(201);
|
|
}
|
|
|
|
/**
|
|
* GET /v1/names/claims
|
|
*
|
|
* List all pending claims (admin only).
|
|
*/
|
|
public function listClaims(): JsonResponse
|
|
{
|
|
$claims = NameClaim::orderByDesc('created_at')->get();
|
|
|
|
return response()->json([
|
|
'claims' => $claims,
|
|
'total' => $claims->count(),
|
|
'pending' => NameClaim::pending()->count(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Matches daemon's validate_alias_name: a-z, 0-9, dash, dot. Max 255 chars.
|
|
* We additionally require at least 1 char (daemon allows empty but we don't).
|
|
*/
|
|
/**
|
|
* POST /v1/names/claims/{id}/approve
|
|
*/
|
|
public function approveClaim(string $id): JsonResponse
|
|
{
|
|
$claim = NameClaim::where('claim_id', $id)->first();
|
|
|
|
if (! $claim) {
|
|
return response()->json(['error' => 'Claim not found'], 404);
|
|
}
|
|
|
|
if ($claim->status !== 'pending') {
|
|
return response()->json(['error' => "Claim already {$claim->status}"], 409);
|
|
}
|
|
|
|
$claim->approve();
|
|
|
|
Models\NameActivity::log($claim->name, 'claim_approved', [
|
|
'claim_id' => $claim->claim_id,
|
|
'email' => $claim->email,
|
|
]);
|
|
|
|
return response()->json([
|
|
'claim_id' => $claim->claim_id,
|
|
'name' => $claim->name,
|
|
'status' => 'approved',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* POST /v1/names/claims/{id}/reject
|
|
*/
|
|
public function rejectClaim(string $id): JsonResponse
|
|
{
|
|
$claim = NameClaim::where('claim_id', $id)->first();
|
|
|
|
if (! $claim) {
|
|
return response()->json(['error' => 'Claim not found'], 404);
|
|
}
|
|
|
|
if ($claim->status !== 'pending') {
|
|
return response()->json(['error' => "Claim already {$claim->status}"], 409);
|
|
}
|
|
|
|
$claim->reject();
|
|
|
|
Models\NameActivity::log($claim->name, 'claim_rejected', [
|
|
'claim_id' => $claim->claim_id,
|
|
]);
|
|
|
|
return response()->json([
|
|
'claim_id' => $claim->claim_id,
|
|
'name' => $claim->name,
|
|
'status' => 'rejected',
|
|
]);
|
|
}
|
|
|
|
private function isValidName(string $name): bool
|
|
{
|
|
return (bool) preg_match('/^[a-z0-9][a-z0-9.\-]{0,254}$/', $name);
|
|
}
|
|
}
|