lthn.io/app/Mod/Proxy/Controllers/ProxyController.php
Claude a5f28d5f6f
security: fix critical + high code review findings
CRITICAL:
- DaemonRpc: only cache successful responses as stale fallback (not errors)
- Records endpoint: replaced file_get_contents with Http::timeout(3)

HIGH:
- WalletRpc: removed exception message from API response (IP leak)
- Ticket/session IDs: replaced MD5(predictable) with random_bytes (CSPRNG)
- Race condition lock: Cache::add() atomic instead of has()+put()

MEDIUM:
- Block caching: getBlockByHeight cached 1hr (blocks are immutable)
- Sunrise meta description: fixed Blade variable syntax

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

240 lines
8.1 KiB
PHP

<?php
declare(strict_types=1);
namespace Mod\Proxy\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Mod\Gateway\Services\GatewayRegistry;
use Mod\Proxy\Services\NodeSelector;
use Mod\Proxy\Services\UsageMeter;
/**
* api.lthn.io proxy gateway API.
*
* POST /v1/proxy/connect — get a gateway connection for your service type
* GET /v1/proxy/usage — check your usage (bytes, requests, GB)
* GET /v1/proxy/nodes — list available nodes by capability
* GET /v1/proxy/status — network availability summary
*/
class ProxyController extends Controller
{
// Pricing per GB in USD (for display — actual billing via Blesta)
private const PRICING = [
'mobile' => 5.00,
'residential' => 2.50,
'seo' => 0.00, // per-request, not per-GB
];
public function __construct(
private readonly NodeSelector $selector,
private readonly UsageMeter $meter,
private readonly GatewayRegistry $registry,
) {}
/**
* POST /v1/proxy/connect {"type": "residential"}
*
* Returns a gateway node to connect to for the requested service type.
* Requires API key (Bearer token from Blesta service).
*/
public function connect(Request $request): JsonResponse
{
$type = strtolower(trim((string) $request->input('type', 'proxy')));
$region = strtolower(trim((string) $request->input('region', '')));
// Map service types to node capabilities
$capMap = [
'mobile' => 'proxy',
'residential' => 'proxy',
'seo' => 'exit',
'vpn' => 'vpn',
'proxy' => 'proxy',
'dns' => 'dns',
];
$capability = $capMap[$type] ?? 'proxy';
$source = 'chain';
// Prefer live paired gateways (real-time stats, verified endpoints)
$liveGateway = $this->registry->selectBest($capability);
if ($liveGateway) {
$source = 'paired';
$apiKey = $request->bearerToken() ?? 'anonymous';
$this->meter->recordRequest($apiKey);
return response()->json([
'node' => $liveGateway['name'] . '.lthn',
'type' => $type,
'capabilities' => $liveGateway['capabilities'] ?? [],
'region' => $liveGateway['region'] ?? 'unknown',
'wireguard_endpoint' => $liveGateway['wireguard_endpoint'] ?? '',
'proxy_endpoint' => $liveGateway['proxy_endpoint'] ?? '',
'load' => $liveGateway['current_load'] ?? 0,
'source' => $source,
'pricing' => [
'model' => $type === 'seo' ? 'per-request' : 'per-gb',
'rate_usd' => self::PRICING[$type] ?? 0,
'currency' => 'LTHN',
],
'session' => bin2hex(random_bytes(8)),
]);
}
// Fall back to chain-discovered nodes
$node = $this->selector->selectNode($capability);
if (! $node) {
return response()->json([
'error' => 'No nodes available for this service type.',
'type' => $type,
], 503);
}
$apiKey = $request->bearerToken() ?? 'anonymous';
$this->meter->recordRequest($apiKey);
$comment = $node['comment'] ?? '';
$caps = [];
if (preg_match('/cap=([^;]+)/', $comment, $m)) {
$caps = explode(',', $m[1]);
}
return response()->json([
'node' => $node['alias'] . '.lthn',
'address' => $node['address'] ?? '',
'type' => $type,
'capabilities' => $caps,
'source' => $source,
'pricing' => [
'model' => $type === 'seo' ? 'per-request' : 'per-gb',
'rate_usd' => self::PRICING[$type] ?? 0,
'currency' => 'LTHN',
],
'session' => bin2hex(random_bytes(8)),
]);
}
/**
* GET /v1/proxy/usage
*
* Returns usage for the authenticated API key.
*/
public function usage(Request $request): JsonResponse
{
$apiKey = $request->bearerToken() ?? 'anonymous';
$usage = $this->meter->getUsage($apiKey);
return response()->json([
'api_key' => substr($apiKey, 0, 8) . '...',
'usage' => $usage,
'billing' => [
'mobile_cost' => round($usage['gb'] * self::PRICING['mobile'], 2),
'residential_cost' => round($usage['gb'] * self::PRICING['residential'], 2),
'seo_requests' => $usage['requests'],
],
]);
}
/**
* GET /v1/proxy/billing/{apiKey}
*
* Billing data for a customer's API key — Blesta queries this via cron.
* Returns usage, cost per tier, and whether to invoice.
*/
public function billing(string $apiKey): JsonResponse
{
$usage = $this->meter->getUsage($apiKey);
$gb = $usage['gb'];
$requests = $usage['requests'];
return response()->json([
'api_key' => $apiKey,
'period' => [
'start' => now()->startOfMonth()->toIso8601String(),
'end' => now()->toIso8601String(),
],
'usage' => $usage,
'charges' => [
'mobile_proxy' => [
'gb' => $gb,
'rate' => self::PRICING['mobile'],
'total' => round($gb * self::PRICING['mobile'], 2),
],
'residential_proxy' => [
'gb' => $gb,
'rate' => self::PRICING['residential'],
'total' => round($gb * self::PRICING['residential'], 2),
],
'seo_traffic' => [
'requests' => $requests,
'rate_per_1k' => 1.00,
'total' => round($requests / 1000 * 1.00, 2),
],
],
'currency' => 'USD',
]);
}
/**
* GET /v1/proxy/nodes?cap=proxy&region=eu
*
* List available nodes by capability.
*/
public function nodes(Request $request): JsonResponse
{
$capability = strtolower(trim((string) $request->get('cap', '')));
$nodes = $this->selector->availableNodes($capability);
$result = [];
foreach ($nodes as $node) {
$caps = [];
if (preg_match('/cap=([^;]+)/', $node['comment'] ?? '', $m)) {
$caps = explode(',', $m[1]);
}
$result[] = [
'name' => $node['alias'] . '.lthn',
'capabilities' => $caps,
'status' => 'online',
];
}
return response()->json([
'nodes' => $result,
'count' => count($result),
'filter' => $capability ?: 'all',
]);
}
/**
* GET /v1/proxy/status
*
* Network availability summary.
*/
public function status(): JsonResponse
{
$vpn = $this->selector->availableNodes('vpn');
$proxy = $this->selector->availableNodes('proxy');
$exit = $this->selector->availableNodes('exit');
$dns = $this->selector->availableNodes('dns');
return response()->json([
'status' => 'operational',
'availability' => [
'vpn' => ['nodes' => count($vpn), 'status' => count($vpn) > 0 ? 'available' : 'unavailable'],
'proxy' => ['nodes' => count($proxy), 'status' => count($proxy) > 0 ? 'available' : 'unavailable'],
'exit' => ['nodes' => count($exit), 'status' => count($exit) > 0 ? 'available' : 'unavailable'],
'dns' => ['nodes' => count($dns), 'status' => count($dns) > 0 ? 'available' : 'unavailable'],
],
'services' => [
'mobile_proxy' => ['available' => count($proxy) > 0, 'rate' => '$5.00/GB'],
'residential_proxy' => ['available' => count($proxy) > 0, 'rate' => '$2.50/GB'],
'seo_traffic' => ['available' => count($exit) > 0, 'rate' => 'per-request'],
],
]);
}
}