lthn.io/app/Mod/Names/Controllers/NamesController.php
Claude 054b2e6161
feat(sunrise): DNS TXT verification for brand claims (HNS-style)
- GET /v1/names/sunrise/verify/{name} — generate verification token
- GET /v1/names/sunrise/check/{name} — check TXT record across .com/.org/.net/.io/.co.uk
- Token: _lthn-verify.brand.com TXT "lthn-verify={hash}"
- Same process as Handshake TLD claims
- Sunrise page shows both auto (DNS) and manual (email) verification paths
- Automatic claim when DNS record matches

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 06:28:58 +01:00

530 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 Mod\Chain\Services\DaemonRpc;
use Mod\Chain\Services\WalletRpc;
/**
* .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
{
$name = strtolower(trim($name));
if (! $this->isValidName($name)) {
return response()->json([
'available' => false,
'reason' => 'Invalid name. Use 1-63 lowercase alphanumeric characters.',
]);
}
$alias = $this->rpc->getAliasByName($name);
$reserved = Cache::has("name_lock:{$name}");
return response()->json([
'name' => $name,
'available' => $alias === null && ! $reserved,
'reserved' => $reserved,
'fqdn' => "{$name}.lthn",
]);
}
/**
* 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);
}
return response()->json([
'name' => $name,
'fqdn' => "{$name}.lthn",
'address' => $alias['address'] ?? '',
'comment' => $alias['comment'] ?? '',
'registered' => true,
]);
}
/**
* 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::has($lockKey)) {
return response()->json([
'error' => 'This name is being registered by another customer. Please try a different name.',
'name' => $name,
], 409);
}
Cache::put($lockKey, true, 600); // 10 minute reservation
// 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) {
$response = @file_get_contents("{$lnsUrl}/resolve?name={$name}&type={$type}");
if ($response) {
$data = json_decode($response, true);
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
{
$name = strtolower(trim($name));
$records = $request->input('records', []);
// Per-name lock prevents concurrent edits from overwriting each other
$editLock = "dns_edit_lock:{$name}";
if (Cache::has($editLock)) {
return response()->json([
'error' => 'A DNS update for this name is already pending. Please wait for chain confirmation.',
'name' => $name,
], 409);
}
// Look up the current alias to get the address
$alias = $this->rpc->getAliasByName($name);
if (! $alias) {
return response()->json(['error' => 'Name not registered'], 404);
}
$address = $alias['address'] ?? '';
// Build the comment with DNS records encoded for LNS
// Format: v=lthn1;type=user;dns=TYPE:HOST:VALUE|TYPE:HOST:VALUE
// Uses pipe separator (not comma) — commas can appear in TXT values
$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);
}
// Try to update the alias on chain via wallet RPC
$result = $this->wallet->updateAlias($name, $address, $comment);
$ticketId = substr(md5($name . time()), 0, 12);
// Track ticket ID for background retry
$ticketIds = Cache::get('dns_ticket_ids', []);
$ticketIds[] = $ticketId;
Cache::put('dns_ticket_ids', array_unique($ticketIds), 86400);
if (isset($result['tx_id'])) {
// Lock this name for edits until block confirms (5 min TTL)
Cache::put($editLock, $ticketId, 300);
Cache::put("dns_ticket:{$ticketId}", [
'name' => $name,
'status' => 'pending',
'tx_id' => $result['tx_id'],
'records' => $records,
'created_at' => now()->toIso8601String(),
], 3600);
return response()->json([
'name' => $name,
'ticket' => $ticketId,
'tx_id' => $result['tx_id'],
'status' => 'pending',
'message' => 'DNS update submitted. Awaiting chain confirmation.',
]);
}
// Chain busy — queue for retry
Cache::put("dns_ticket:{$ticketId}", [
'name' => $name,
'status' => 'queued',
'records' => $records,
'comment' => $comment,
'address' => $address,
'error' => $result['message'] ?? ($result['error'] ?? 'Chain busy'),
'created_at' => now()->toIso8601String(),
], 3600);
return response()->json([
'name' => $name,
'ticket' => $ticketId,
'status' => 'queued',
'message' => 'DNS update queued. Will be processed when chain is ready.',
], 202);
}
/**
* GET /v1/names/ticket/{id}
*
* Check the status of a DNS change ticket.
*/
public function ticket(string $id): JsonResponse
{
$ticket = Cache::get("dns_ticket:{$id}");
if (! $ticket) {
return response()->json(['error' => 'Ticket not found'], 404);
}
// If pending, check if the tx has confirmed
if ($ticket['status'] === 'pending' && ! empty($ticket['tx_id'])) {
$alias = $this->rpc->getAliasByName($ticket['name']);
if ($alias && str_contains($alias['comment'] ?? '', 'dns=')) {
$ticket['status'] = 'confirmed';
Cache::put("dns_ticket:{$id}", $ticket, 3600);
Cache::forget("dns_edit_lock:{$ticket['name']}");
}
}
return response()->json([
'ticket' => $id,
'name' => $ticket['name'],
'status' => $ticket['status'],
'tx_id' => $ticket['tx_id'] ?? null,
'created_at' => $ticket['created_at'] ?? null,
]);
}
/**
* 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",
'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",
],
]);
}
/**
* 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. Contact developers@lethean.io to complete the claim.'
: 'TXT record not found. Add the record and allow DNS propagation (up to 48h).',
]);
}
/**
* 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).
*/
private function isValidName(string $name): bool
{
return (bool) preg_match('/^[a-z0-9][a-z0-9.\-]{0,254}$/', $name);
}
}