- POST /v1/names/records/{name} returns ticket ID for tracking
- GET /v1/names/ticket/{id} checks status (queued/pending/confirmed)
- Queue gracefully handles busy chain (202 Accepted)
- Ticket auto-checks confirmation against chain alias
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
338 lines
11 KiB
PHP
338 lines
11 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);
|
|
|
|
return response()->json([
|
|
'name' => $name,
|
|
'available' => $alias === null,
|
|
'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);
|
|
}
|
|
|
|
// Check availability on chain
|
|
$existing = $this->rpc->getAliasByName($name);
|
|
if ($existing !== null) {
|
|
return response()->json([
|
|
'error' => 'Name already registered.',
|
|
'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;
|
|
}
|
|
|
|
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', []);
|
|
|
|
// 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
|
|
$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);
|
|
|
|
if (isset($result['tx_id'])) {
|
|
// Immediate success — tx submitted
|
|
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);
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'ticket' => $id,
|
|
'name' => $ticket['name'],
|
|
'status' => $ticket['status'],
|
|
'tx_id' => $ticket['tx_id'] ?? null,
|
|
'created_at' => $ticket['created_at'] ?? null,
|
|
]);
|
|
}
|
|
|
|
private function isValidName(string $name): bool
|
|
{
|
|
return (bool) preg_match('/^[a-z0-9][a-z0-9-]{0,62}$/', $name);
|
|
}
|
|
}
|