Initial commit

This commit is contained in:
Rekryt
2024-08-30 15:22:24 +03:00
commit 171e449744
46 changed files with 4638 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
<?php
namespace OpenCCK\App\Controller;
use Amp\Http\HttpStatus;
use Amp\Http\Server\Request;
use Amp\Http\Server\Response;
use Throwable;
abstract class AbstractController implements ControllerInterface {
private int $httpStatus = HttpStatus::OK;
/**
* @param Request $request
* @param array $headers
* @throws Throwable
*/
public function __construct(protected Request $request, protected array $headers = []) {
}
/**
* @return Response
*/
public function __invoke(): Response {
return new Response(status: $this->httpStatus, headers: $this->headers, body: $this->getBody());
}
abstract public function getBody(): string;
public function setHeaders(array $headers): AbstractController {
$this->headers = $headers;
return $this;
}
/**
* @param int $httpStatus
*/
public function setHttpStatus(int $httpStatus): void {
$this->httpStatus = $httpStatus;
}
public function redirect(string $url, bool $permanently = false): void {
$this->httpStatus = $permanently ? HttpStatus::MOVED_PERMANENTLY : HttpStatus::SEE_OTHER;
$this->headers = array_merge($this->headers ?? [], ['location' => $url]);
}
/**
* @return string
*/
public function getBaseURL(): string {
$schemePort = ['http' => 80, 'https' => 443];
return $this->request->getUri()->getScheme() .
'://' .
$this->request->getUri()->getHost() .
($schemePort[$this->request->getUri()->getScheme()] !== $this->request->getUri()->getPort()
? ':' . $this->request->getUri()->getPort()
: '');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace OpenCCK\App\Controller;
use Amp\ByteStream\BufferException;
use Amp\Http\Server\Request;
use OpenCCK\App\Service\IPListService;
use OpenCCK\Infrastructure\API\App;
use Monolog\Logger;
use Throwable;
abstract class AbstractIPListController extends AbstractController {
protected Logger $logger;
protected IPListService $service;
/**
* @param Request $request
* @param array $headers
* @throws BufferException
* @throws Throwable
*/
public function __construct(protected Request $request, protected array $headers = []) {
parent::__construct($request, $this->headers);
$this->logger = App::getLogger();
$this->service = IPListService::getInstance();
}
/**
* @return string
*/
abstract public function getBody(): string;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace OpenCCK\App\Controller;
use Amp\Http\Server\Request;
use Amp\Http\Server\Response;
interface ControllerInterface {
/**
* @param Request $request
* @param array $headers
*/
public function __construct(Request $request, array $headers = []);
/**
* @return Response
*/
public function __invoke(): Response;
}

View File

@@ -0,0 +1,32 @@
<?php
namespace OpenCCK\App\Controller;
class JsonController extends AbstractIPListController {
/**
* @return string
*/
public function getBody(): string {
$this->setHeaders(['content-type' => 'application/json']);
$site = $this->request->getQueryParameter('site') ?? '';
$data = $this->request->getQueryParameter('data') ?? '';
if ($site == '') {
if ($data == '') {
return json_encode($this->service->sites);
} else {
$result = [];
foreach ($this->service->sites as $site) {
$result[$site->name] = $site->$data;
}
return json_encode($result);
}
} else {
if ($data == '') {
return json_encode($this->service->sites[$site]);
} else {
return json_encode($this->service->sites[$site]->$data);
}
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace OpenCCK\App\Controller;
class MainController extends AbstractIPListController {
/**
* @return string
*/
public function getBody(): string {
$this->setHeaders(['content-type' => 'text/html; charset=utf-8']);
return $this->renderTemplate('index');
}
/**
* @param string $template
* @return string
*/
private function renderTemplate(string $template): string {
ob_start();
include PATH_ROOT . '/src/App/Template/' . ucfirst($template) . 'Template.php';
return ob_get_clean();
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace OpenCCK\App\Controller;
class MikrotikController extends AbstractIPListController {
/**
* @return string
*/
public function getBody(): string {
$this->setHeaders(['content-type' => 'text/plain']);
$site = $this->request->getQueryParameter('site') ?? '';
$data = $this->request->getQueryParameter('data') ?? '';
if ($data == '') {
return "# Error: The 'data' GET parameter is required in the URL to access this page, but it cannot have the value 'All'";
}
$response = '/ip firewall address-list' . "\n";
if ($site == '') {
foreach ($this->service->sites as $site) {
$response .= $this->render($site->name, $site->$data);
}
return $response;
} else {
return $response . $this->render($site, $this->service->sites[$site]->$data);
}
}
/**
* @param string $site
* @param iterable $array
* @return string
*/
private function render(string $site, iterable $array): string {
$response = '';
$listName = str_replace(' ', '', $site);
foreach ($array as $item) {
$response .= 'add list=' . $listName . ' address=' . $item . ' comment=' . $listName . "\n";
}
return $response;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace OpenCCK\App\Controller;
class TextController extends AbstractIPListController {
/**
* @return string
*/
public function getBody(): string {
$this->setHeaders(['content-type' => 'text/plain']);
$site = $this->request->getQueryParameter('site') ?? '';
$data = $this->request->getQueryParameter('data') ?? '';
if ($data == '') {
return "# Error: The 'data' GET parameter is required in the URL to access this page, but it cannot have the value 'All'";
}
$response = '';
if ($site == '') {
foreach ($this->service->sites as $site) {
$response .= $this->render($site->name, $site->$data);
}
return $response;
} else {
return $this->render($site, $this->service->sites[$site]->$data);
}
}
private function render(string $name, iterable $array): string {
$response = '# ' . $name . ' ' . date('Y-m-d H:i:s') . "\n";
foreach ($array as $item) {
$response .= $item . "\n";
}
return $response;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace OpenCCK\App\Service;
use OpenCCK\Domain\Entity\Site;
use OpenCCK\Domain\Factory\SiteFactory;
use OpenCCK\Infrastructure\API\App;
use Exception;
use Monolog\Logger;
class IPListService {
private static IPListService $_instance;
private ?Logger $logger;
/**
* @var array<string, Site>
*/
public array $sites = [];
/**
* @throws Exception
*/
private function __construct(Logger $logger = null) {
$this->logger = $logger ?? App::getLogger();
$dir = PATH_ROOT . '/config/';
if (!is_dir($dir)) {
throw new Exception('config directory not found');
}
foreach (scandir($dir) as $file) {
if (str_ends_with($file, '.json')) {
$this->loadConfig(substr($file, 0, -5), json_decode(file_get_contents($dir . $file)));
}
}
}
/**
* @param ?Logger $logger
* @return IPListService
* @throws Exception
*/
public static function getInstance(Logger $logger = null): IPListService {
return self::$_instance ??= new self($logger);
}
private function loadConfig(string $name, object $config): void {
$this->sites[$name] = SiteFactory::create($name, $config);
}
}

View File

@@ -0,0 +1,588 @@
<?php
use OpenCCK\App\Controller\TextController;
/** @var TextController $this */
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>IPList</title>
<style>
/* MVP.css v1.15 - https://github.com/andybrewer/mvp */
:root {
--active-brightness: 0.85;
--border-radius: 5px;
--box-shadow: 2px 2px 10px;
--color-accent: #118bee15;
--color-bg: #fff;
--color-bg-secondary: #e9e9e9;
--color-link: #118bee;
--color-secondary: #920de9;
--color-secondary-accent: #920de90b;
--color-shadow: #f4f4f4;
--color-table: #118bee;
--color-text: #000;
--color-text-secondary: #999;
--color-scrollbar: #cacae8;
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
--hover-brightness: 1.2;
--justify-important: center;
--justify-normal: left;
--line-height: 1.5;
--width-card: 285px;
--width-card-medium: 460px;
--width-card-wide: 800px;
--width-content: 1080px;
}
@media (prefers-color-scheme: dark) {
:root[color-mode="user"] {
--color-accent: #0097fc4f;
--color-bg: #333;
--color-bg-secondary: #555;
--color-link: #0097fc;
--color-secondary: #e20de9;
--color-secondary-accent: #e20de94f;
--color-shadow: #bbbbbb20;
--color-table: #0097fc;
--color-text: #f7f7f7;
--color-text-secondary: #aaa;
}
}
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
/* Layout */
article aside {
background: var(--color-secondary-accent);
border-left: 4px solid var(--color-secondary);
padding: 0.01rem 0.8rem;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family);
line-height: var(--line-height);
margin: 0;
overflow-x: hidden;
padding: 0;
}
footer,
header,
main {
margin: 0 auto;
max-width: var(--width-content);
padding: 3rem 1rem;
}
hr {
background-color: var(--color-bg-secondary);
border: none;
height: 1px;
margin: 4rem 0;
width: 100%;
}
section {
display: flex;
flex-wrap: wrap;
justify-content: var(--justify-important);
}
section img,
article img {
max-width: 100%;
}
section pre {
overflow: auto;
}
section aside {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
margin: 1rem;
padding: 1.25rem;
width: var(--width-card);
}
section aside:hover {
box-shadow: var(--box-shadow) var(--color-bg-secondary);
}
[hidden] {
display: none;
}
/* Headers */
article header,
div header,
main header {
padding-top: 0;
}
header {
text-align: var(--justify-important);
}
header a b,
header a em,
header a i,
header a strong {
margin-left: 0.5rem;
margin-right: 0.5rem;
}
header nav img {
margin: 1rem 0;
}
section header {
padding-top: 0;
width: 100%;
}
/* Nav */
nav {
align-items: center;
display: flex;
font-weight: bold;
justify-content: space-between;
margin-bottom: 7rem;
}
nav ul {
list-style: none;
padding: 0;
}
nav ul li {
display: inline-block;
margin: 0 0.5rem;
position: relative;
text-align: left;
}
/* Nav Dropdown */
nav ul li:hover ul {
display: block;
}
nav ul li ul {
background: var(--color-bg);
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
display: none;
height: auto;
left: -2px;
padding: .5rem 1rem;
position: absolute;
top: 1.7rem;
white-space: nowrap;
width: auto;
z-index: 1;
}
nav ul li ul::before {
/* fill gap above to make mousing over them easier */
content: "";
position: absolute;
left: 0;
right: 0;
top: -0.5rem;
height: 0.5rem;
}
nav ul li ul li,
nav ul li ul li a {
display: block;
}
/* Typography */
code,
samp {
background-color: var(--color-accent);
border-radius: var(--border-radius);
color: var(--color-text);
display: inline-block;
margin: 0 0.1rem;
padding: 0 0.5rem;
}
details {
margin: 1.3rem 0;
}
details summary {
font-weight: bold;
cursor: pointer;
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: var(--line-height);
}
mark {
padding: 0.1rem;
}
ol li,
ul li {
padding: 0.2rem 0;
}
p {
margin: 0.75rem 0;
padding: 0;
width: 100%;
}
pre {
margin: 1rem 0;
max-width: var(--width-card-wide);
padding: 1rem 0;
}
pre code,
pre samp {
display: block;
max-width: var(--width-card-wide);
padding: 0.5rem 2rem;
white-space: pre-wrap;
}
small {
color: var(--color-text-secondary);
}
sup {
background-color: var(--color-secondary);
border-radius: var(--border-radius);
color: var(--color-bg);
font-size: xx-small;
font-weight: bold;
margin: 0.2rem;
padding: 0.2rem 0.3rem;
position: relative;
top: -2px;
}
/* Links */
a {
color: var(--color-link);
display: inline-block;
font-weight: bold;
text-decoration: underline;
}
a:hover {
filter: brightness(var(--hover-brightness));
}
a:active {
filter: brightness(var(--active-brightness));
}
a b,
a em,
a i,
a strong,
button,
input[type="submit"] {
border-radius: var(--border-radius);
display: inline-block;
font-size: medium;
font-weight: bold;
line-height: var(--line-height);
margin: 0.5rem 0;
padding: 1rem 2rem;
}
button,
input[type="submit"] {
font-family: var(--font-family);
}
button:hover,
input[type="submit"]:hover {
cursor: pointer;
filter: brightness(var(--hover-brightness));
}
button:active,
input[type="submit"]:active {
filter: brightness(var(--active-brightness));
}
a b,
a strong,
button,
input[type="submit"] {
background-color: var(--color-link);
border: 2px solid var(--color-link);
color: var(--color-bg);
}
a em,
a i {
border: 2px solid var(--color-link);
border-radius: var(--border-radius);
color: var(--color-link);
display: inline-block;
padding: 1rem 2rem;
}
article aside a {
color: var(--color-secondary);
}
/* Images */
figure {
margin: 0;
padding: 0;
}
figure img {
max-width: 100%;
}
figure figcaption {
color: var(--color-text-secondary);
}
/* Forms */
button:disabled,
input:disabled {
background: var(--color-bg-secondary);
border-color: var(--color-bg-secondary);
color: var(--color-text-secondary);
cursor: not-allowed;
}
button[disabled]:hover,
input[type="submit"][disabled]:hover {
filter: none;
}
form {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
display: block;
max-width: var(--width-card-wide);
min-width: var(--width-card);
padding: 1.5rem;
text-align: var(--justify-normal);
}
form header {
margin: 1.5rem 0;
padding: 1.5rem 0;
}
input,
label,
select,
textarea {
display: block;
font-size: inherit;
max-width: var(--width-card-wide);
}
input[type="checkbox"],
input[type="radio"] {
display: inline-block;
}
input[type="checkbox"]+label,
input[type="radio"]+label {
display: inline-block;
font-weight: normal;
position: relative;
top: 1px;
}
input[type="range"] {
padding: 0.4rem 0;
}
input,
select,
textarea {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
margin-bottom: 1rem;
padding: 0.4rem 0.8rem;
}
input[type="text"],
input[type="password"],
textarea {
width: calc(100% - 1.6rem);
}
input[readonly],
textarea[readonly] {
background-color: var(--color-bg-secondary);
}
label {
font-weight: bold;
margin-bottom: 0.2rem;
}
/* Popups */
dialog {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow) var(--color-shadow);
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 50%;
z-index: 999;
}
/* Tables */
table {
border: 1px solid var(--color-bg-secondary);
border-radius: var(--border-radius);
border-spacing: 0;
display: inline-block;
max-width: 100%;
overflow-x: auto;
padding: 0;
white-space: nowrap;
}
table td,
table th,
table tr {
padding: 0.4rem 0.8rem;
text-align: var(--justify-important);
}
table thead {
background-color: var(--color-table);
border-collapse: collapse;
border-radius: var(--border-radius);
color: var(--color-bg);
margin: 0;
padding: 0;
}
table thead tr:first-child th:first-child {
border-top-left-radius: var(--border-radius);
}
table thead tr:first-child th:last-child {
border-top-right-radius: var(--border-radius);
}
table thead th:first-child,
table tr td:first-child {
text-align: var(--justify-normal);
}
table tr:nth-child(even) {
background-color: var(--color-accent);
}
/* Quotes */
blockquote {
display: block;
font-size: x-large;
line-height: var(--line-height);
margin: 1rem auto;
max-width: var(--width-card-medium);
padding: 1.5rem 1rem;
text-align: var(--justify-important);
}
blockquote footer {
color: var(--color-text-secondary);
display: block;
font-size: small;
line-height: var(--line-height);
padding: 1.5rem 0;
}
/* Scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-scrollbar) transparent;
}
*::-webkit-scrollbar {
width: 5px;
height: 5px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar);
border-radius: 10px;
}
</style>
</head>
<body>
<form action="" method="get">
<section>
<label>
Format:
<select name="format">
<option value="json">JSON</option>
<option value="text">Text</option>
<option value="mikrotik">MikroTik</option>
</select>
</label>
<label>
Site:
<select name="site">
<option value="">All</option>
<?php foreach ($this->service->sites as $site): ?>
<option value="<?= $site->name ?>"><?= $site->name ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
Data:
<select name="data">
<option value="">All</option>
<option value="domains">domains</option>
<option value="ip4">ip4</option>
<option value="cidr4">cidr4</option>
<option value="ip6">ip6</option>
<option value="cidr6">cidr6</option>
</select>
</label>
</section>
<section>
<button type="submit">Submit</button>
</section>
</form>
</body>
</html>

115
src/Domain/Entity/Site.php Normal file
View File

@@ -0,0 +1,115 @@
<?php
namespace OpenCCK\Domain\Entity;
use OpenCCK\Domain\Factory\SiteFactory;
use OpenCCK\Domain\Helper\DNSHelper;
use OpenCCK\Domain\Helper\IP4Helper;
use OpenCCK\Domain\Helper\IP6Helper;
use OpenCCK\Infrastructure\API\App;
use Revolt\EventLoop;
use stdClass;
final class Site {
private DNSHelper $dnsHelper;
/**
* @param string $name Name of portal
* @param array $domains List of portal domains
* @param array $dns List of DNS servers for updating IP addresses
* @param int $timeout Time interval between domain IP address updates (seconds)
* @param array $ip4 List of IPv4 addresses
* @param array $ip6 List of IPv6 addresses
* @param array $cidr4 List of CIDRv4 zones of IPv4 addresses
* @param array $cidr6 List of CIDRv6 zones of IPv6 addresses
* @param object $external Lists of URLs to retrieve data from external sources
*
*/
public function __construct(
public string $name,
public array $domains = [],
public array $dns = [],
public int $timeout = 1440 * 60,
public array $ip4 = [],
public array $ip6 = [],
public array $cidr4 = [],
public array $cidr6 = [],
public object $external = new stdClass()
) {
$this->dnsHelper = new DNSHelper($dns);
EventLoop::delay(0, $this->reload(...));
}
/**
* @return void
*/
private function reload(): void {
$ip4 = [];
$ip6 = [];
foreach ($this->domains as $domain) {
[$ipv4results, $ipv6results] = $this->dnsHelper->resolve($domain);
$ip4 = array_merge($ip4, $ipv4results);
$ip6 = array_merge($ip6, $ipv6results);
}
$newIp4 = SiteFactory::normalize(array_diff($ip4, $this->ip4));
$this->cidr4 = SiteFactory::normalize(IP4Helper::processCIDR($newIp4, $this->cidr4));
$newIp6 = SiteFactory::normalize(array_diff($ip6, $this->ip6));
$this->cidr6 = SiteFactory::normalize(IP6Helper::processCIDR($newIp6, $this->cidr6));
$this->ip4 = SiteFactory::normalize(array_merge($this->ip4, $ip4));
$this->ip6 = SiteFactory::normalize(array_merge($this->ip6, $ip6));
App::getLogger()->debug('Reloaded for ' . $this->name);
EventLoop::delay($this->timeout, function () {
$this->reloadExternal();
$this->reload();
});
}
/**
* @return void
*/
private function reloadExternal(): void {
if (isset($this->external->domains)) {
foreach ($this->external->domains as $url) {
$this->domains = SiteFactory::normalize(
array_merge($this->domains, explode("\n", file_get_contents($url)))
);
}
}
if (isset($this->external->ip4)) {
foreach ($this->external->ip4 as $url) {
$this->ip4 = SiteFactory::normalize(array_merge($this->ip4, explode("\n", file_get_contents($url))));
}
}
if (isset($this->external->ip6)) {
foreach ($this->external->ip6 as $url) {
$this->ip6 = SiteFactory::normalize(array_merge($this->ip6, explode("\n", file_get_contents($url))));
}
}
if (isset($this->external->cidr4)) {
foreach ($this->external->cidr4 as $url) {
$this->cidr4 = SiteFactory::normalize(
array_merge($this->cidr4, explode("\n", file_get_contents($url)))
);
}
}
if (isset($this->external->cidr6)) {
foreach ($this->external->cidr6 as $url) {
$this->cidr6 = SiteFactory::normalize(
array_merge($this->cidr6, explode("\n", file_get_contents($url)))
);
}
}
App::getLogger()->debug('External reloaded for ' . $this->name);
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace OpenCCK\Domain\Factory;
use OpenCCK\Domain\Entity\Site;
use OpenCCK\Domain\Helper\IP4Helper;
use OpenCCK\Domain\Helper\IP6Helper;
use stdClass;
class SiteFactory {
/**
* @param string $name Name of portal
* @param object $config Configuration of portal
* @return Site
*
*/
static function create(string $name, object $config): Site {
$domains = $config->domains ?? [];
$dns = $config->dns ?? [];
$timeout = $config->timeout ?? 1440 * 60;
$ip4 = $config->ip4 ?? [];
$ip6 = $config->ip6 ?? [];
$cidr4 = $config->cidr4 ?? [];
$cidr6 = $config->cidr6 ?? [];
$external = $config->external ?? new stdClass();
if (isset($external)) {
if (isset($external->domains)) {
foreach ($external->domains as $url) {
$domains = array_merge($domains, explode("\n", file_get_contents($url)));
}
}
if (isset($external->ip4)) {
foreach ($external->ip4 as $url) {
$ip4 = array_merge($ip4, explode("\n", file_get_contents($url)));
}
}
if (isset($external->ip6)) {
foreach ($external->ip6 as $url) {
$ip6 = array_merge($ip6, explode("\n", file_get_contents($url)));
}
}
if (isset($external->cidr4)) {
foreach ($external->cidr4 as $url) {
$cidr4 = array_merge($cidr4, explode("\n", file_get_contents($url)));
}
}
if (isset($external->cidr6)) {
foreach ($external->cidr6 as $url) {
$cidr6 = array_merge($cidr6, explode("\n", file_get_contents($url)));
}
}
}
$domains = self::normalize($domains);
$ip4 = self::normalize($ip4);
$ip6 = self::normalize($ip6);
$cidr4 = self::normalize(IP4Helper::processCIDR($ip4, self::normalize($cidr4)));
$cidr6 = self::normalize(IP6Helper::processCIDR($ip6, self::normalize($cidr6)));
return new Site($name, $domains, $dns, $timeout, $ip4, $ip6, $cidr4, $cidr6, $external);
}
/**
* @param array $array
* @return array
*/
public static function normalize(array $array): array {
return array_values(
array_unique(array_filter($array, fn(string $item) => !str_starts_with($item, '#') && strlen($item) > 0))
);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace OpenCCK\Domain\Helper;
use Amp\Dns\DnsConfig;
use Amp\Dns\DnsConfigLoader;
use Amp\Dns\DnsException;
use Amp\Dns\DnsRecord;
use Amp\Dns\HostLoader;
use Amp\Dns\Rfc1035StubDnsResolver;
use OpenCCK\Infrastructure\API\App;
use function Amp\Dns\dnsResolver;
use function Amp\Dns\resolve;
readonly class DNSHelper {
public function __construct(private array $dnsServers = []) {
}
/**
* @param array $dnsServers
* @return void
*/
private function setResolver(array $dnsServers): void {
dnsResolver(
new Rfc1035StubDnsResolver(
null,
new class ($dnsServers) implements DnsConfigLoader {
public function __construct(private readonly array $dnsServers = []) {
}
public function loadConfig(): DnsConfig {
return new DnsConfig($this->dnsServers, (new HostLoader())->loadHosts());
}
}
)
);
}
/**
* @param string $domain
* @return array[]
*/
public function resolve(string $domain): array {
$ipv4 = [];
$ipv6 = [];
foreach ($this->dnsServers as $server) {
$this->setResolver([$server]);
try {
$ipv4 = array_merge(
$ipv4,
array_map(fn(DnsRecord $record) => $record->getValue(), resolve($domain, DnsRecord::A))
);
} catch (DnsException $e) {
App::getLogger()->error($e->getMessage(), [$server]);
}
try {
$ipv6 = array_merge(
$ipv6,
array_map(fn(DnsRecord $record) => $record->getValue(), resolve($domain, DnsRecord::AAAA))
);
} catch (DnsException $e) {
App::getLogger()->error($e->getMessage(), [$server]);
}
}
App::getLogger()->debug('resolve: ' . $domain, [count($ipv4), count($ipv6)]);
return [$ipv4, $ipv6];
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace OpenCCK\Domain\Helper;
use OpenCCK\Infrastructure\API\App;
use OpenCCK\Infrastructure\Storage\CIDRStorage;
use function Amp\async;
use function Amp\delay;
class IP4Helper {
public static function processCIDR(array $ips, $results = []): array {
$count = count($ips);
foreach ($ips as $i => $ip) {
if ($ip === '127.0.0.1') {
continue;
}
async(function () use ($ip, $i, $count, &$results) {
if (CIDRStorage::getInstance()->has($ip)) {
$searchArray = CIDRStorage::getInstance()->get($ip);
$results = array_merge($results, self::trimCIDRs($searchArray));
App::getLogger()->debug($ip . ' -> ' . json_encode($searchArray), [$i + 1 . '/' . $count]);
return;
}
if (self::isInRange($ip, $results)) {
return;
}
$search = null;
$result = shell_exec('whois ' . $ip . ' | grep CIDR');
if ($result) {
preg_match('/^CIDR:\s*(.*)$/m', $result, $matches);
$search = $matches[1] ?? null;
}
if (!$search) {
$search = shell_exec(
implode(' | ', [
'whois -a ' . $ip,
'grep inetnum',
'head -n 1',
"awk '{print $2\"-\"$4}'",
'sed "s/-$//"',
'xargs ipcalc',
"grep -oE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$'",
])
);
}
if (!$search) {
$search = shell_exec(
implode(' | ', [
'whois -a ' . $ip,
'grep IPv4',
'grep " - "',
'head -n 1',
"awk '{print $3\"-\"$5}'",
'xargs ipcalc',
"grep -oE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$'",
])
);
}
if ($search) {
$search = strtr($search, ["\n" => ' ', ', ' => ' ']);
$searchArray = array_filter(
explode(' ', strtr($search, ' ', '')),
fn(string $cidr) => strlen($cidr) > 0
);
CIDRStorage::getInstance()->set($ip, $searchArray);
$results = array_merge($results, self::trimCIDRs($searchArray));
App::getLogger()->debug($ip . ' -> ' . json_encode($searchArray), [$i + 1 . '/' . $count]);
} else {
App::getLogger()->error($ip . ' -> CIDR not found', [$i + 1 . '/' . $count]);
}
delay(0.001);
})->await();
}
return self::minimizeSubnets($results);
}
public static function trimCIDRs(array $searchArray): array {
$subnets = [];
foreach ($searchArray as $search) {
foreach (explode(' ', $search) as $cidr) {
if (str_contains($cidr, '/')) {
$subnets[] = trim($cidr);
}
}
}
return $subnets;
}
public static function sortSubnets(array $subnets): array {
usort($subnets, function ($a, $b) {
return (int) explode('/', $a)[1] - (int) explode('/', $b)[1];
});
usort($subnets, function ($a, $b) {
return ip2long(explode('/', $a)[0]) - ip2long(explode('/', $b)[0]);
});
return $subnets;
}
public static function minimizeSubnets(array $subnets): array {
$result = [];
foreach (self::sortSubnets(array_filter($subnets, fn(string $subnet) => !!$subnet)) as $subnet) {
$include = true;
[$ip /*, $mask*/] = explode('/', $subnet);
$ipLong = ip2long($ip);
// $maskLong = ~((1 << 32 - (int) $mask) - 1);
foreach ($result as $resSubnet) {
[$resIp, $resMask] = explode('/', $resSubnet);
$resIpLong = ip2long($resIp);
$resMaskLong = ~((1 << 32 - (int) $resMask) - 1);
if (($ipLong & $resMaskLong) === ($resIpLong & $resMaskLong)) {
$include = false;
break;
}
}
if ($include) {
$result[] = $subnet;
}
}
return $result;
}
public static function isInRange(string $ip, array $cidrs): bool {
foreach ($cidrs as $cidr) {
[$subnet, $mask] = explode('/', $cidr);
if ((ip2long($ip) & ~((1 << 32 - $mask) - 1)) === ip2long($subnet)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,176 @@
<?php
namespace OpenCCK\Domain\Helper;
use OpenCCK\Infrastructure\API\App;
use OpenCCK\Infrastructure\Storage\CIDRStorage;
use function Amp\async;
use function Amp\delay;
class IP6Helper {
public static function processCIDR(array $ips, $results = []): array {
$count = count($ips);
foreach ($ips as $i => $ip) {
if ($ip === '::1') {
continue;
}
async(function () use ($ip, $i, $count, &$results) {
if (CIDRStorage::getInstance()->has($ip)) {
$searchArray = CIDRStorage::getInstance()->get($ip);
$results = array_merge($results, self::trimCIDRs($searchArray));
App::getLogger()->debug($ip . ' -> ' . json_encode($searchArray), [$i + 1 . '/' . $count]);
return;
}
if (self::isInRange($ip, $results)) {
return;
}
$search = shell_exec(
implode(' | ', [
'whois -a ' . $ip,
'grep inet6num',
'grep -v "/0"',
'head -n 1',
"awk '{print $2}'",
])
);
if (!$search) {
$search = shell_exec(
implode(' | ', [
'whois -a ' . $ip,
'grep route6',
'grep -v "/0"',
'head -n 1',
"awk '{print $2}'",
])
);
}
if ($search) {
$search = strtr($search, ["\n" => ' ', ', ' => ' ']);
$searchArray = array_filter(
explode(' ', strtr($search, ' ', '')),
fn(string $cidr) => strlen($cidr) > 0
);
CIDRStorage::getInstance()->set($ip, $searchArray);
$results = array_merge($results, self::trimCIDRs($searchArray));
App::getLogger()->debug($ip . ' -> ' . json_encode($searchArray), [$i + 1 . '/' . $count]);
} else {
App::getLogger()->error($ip . ' -> CIDR not found', [$i + 1 . '/' . $count]);
}
delay(0.001);
})->await();
}
//return self::minimizeSubnets($results);
return $results;
}
/**
* @param array $searchArray
* @return array
*/
public static function trimCIDRs(array $searchArray): array {
$subnets = [];
foreach ($searchArray as $search) {
foreach (explode(' ', $search) as $cidr) {
if (str_contains($cidr, '/')) {
[$address, $prefix] = explode('/', trim($cidr));
$subnets[] = $address . '/' . max($prefix, 64);
}
}
}
return $subnets;
}
/**
* @param array $subnets
* @return array
*/
public static function sortSubnets(array $subnets): array {
usort($subnets, function ($a, $b) {
return (int) explode('/', $a)[1] - (int) explode('/', $b)[1];
});
usort($subnets, function ($a, $b) {
$ipA = inet_pton(explode('/', $a)[0]);
$ipB = inet_pton(explode('/', $b)[0]);
return strcmp($ipA, $ipB);
});
return $subnets;
}
/**
* @param array $subnets
* @return array
*/
public static function minimizeSubnets(array $subnets): array {
$result = [];
foreach (self::sortSubnets(array_filter($subnets, fn(string $subnet) => !!$subnet)) as $subnet) {
if (!$subnet) {
continue;
}
[$address, $prefix] = explode('/', $subnet);
$addressNum = inet_pton($address);
$addressNum = unpack('J', $addressNum)[1];
$isUnique = true;
foreach ($result as $existingCidr) {
[$existingAddress, $existingPrefix] = explode('/', $existingCidr);
$existingAddressNum = inet_pton($existingAddress);
$existingAddressNum = unpack('J', $existingAddressNum)[1];
$mask = (1 << 128) - (1 << 128 - $prefix);
$existingMask = (1 << 128) - (1 << 128 - $existingPrefix);
if (($addressNum & $mask) === ($existingAddressNum & $existingMask)) {
$isUnique = false;
break;
}
}
if ($isUnique) {
$result[] = $subnet;
}
}
return $result;
}
public static function isInRange(string $ip, array $cidrs): bool {
$ip = inet_pton($ip);
foreach ($cidrs as $cidr) {
[$subnet, $mask] = explode('/', $cidr);
$subnet = inet_pton($subnet);
$mask = intval($mask);
$binaryMask = str_repeat('f', $mask >> 2);
switch ($mask % 4) {
case 1:
$binaryMask .= '8';
break;
case 2:
$binaryMask .= 'c';
break;
case 3:
$binaryMask .= 'e';
break;
}
$binaryMask = str_pad($binaryMask, 32, '0');
$mask = pack('H*', $binaryMask);
if (($ip & $mask) === ($subnet & $mask)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace OpenCCK\Infrastructure\API;
use Amp\ByteStream\WritableResourceStream;
use Amp\Log\ConsoleFormatter;
use Amp\Log\StreamHandler;
use Revolt\EventLoop;
use Revolt\EventLoop\UnsupportedFeatureException;
use Closure;
use Dotenv\Dotenv;
use Monolog\Logger;
use Psr\Log\LogLevel;
use function Amp\trapSignal;
use function OpenCCK\getEnv;
use function sprintf;
final class App {
private static App $_instance;
/**
* @param array<AppModuleInterface> $modules
*/
private array $modules = [];
private bool $isEventLoopStarted = false;
/**
* @param ?Logger $logger
*/
private function __construct(private ?Logger $logger = null) {
ini_set('memory_limit', getEnv('SYS_MEMORY_LIMIT') ?? '2048M');
if (!defined('PATH_ROOT')) {
define('PATH_ROOT', dirname(__DIR__, 3));
}
$dotenv = Dotenv::createImmutable(PATH_ROOT);
$dotenv->safeLoad();
if ($timezone = getEnv('SYS_TIMEZONE')) {
date_default_timezone_set($timezone);
}
$this->logger = $logger ?? new Logger(getEnv('COMPOSE_PROJECT_NAME') ?? 'iplist');
$logHandler = new StreamHandler(new WritableResourceStream(STDOUT));
$logHandler->setFormatter(new ConsoleFormatter());
$logHandler->setLevel(getEnv('DEBUG') === 'false' ? LogLevel::INFO : LogLevel::DEBUG);
$this->logger->pushHandler($logHandler);
EventLoop::setErrorHandler(function ($e) {
$this->logger->error($e->getMessage());
});
}
public static function getInstance(?Logger $logger = null): self {
return self::$_instance ??= new self($logger);
}
/**
* @param Closure<AppModuleInterface> $handler
* @return $this
*/
public function addModule(Closure $handler): self {
$module = $handler($this);
$this->modules[$module::class] = $module;
return $this;
}
public function getModules(): array {
return $this->modules;
}
public static function getLogger(): ?Logger {
return self::$_instance->logger;
}
public function start(): void {
foreach ($this->getModules() as $module) {
$module->start();
}
if (defined('SIGINT') && defined('SIGTERM')) {
// Await SIGINT or SIGTERM to be received.
try {
$signal = trapSignal([SIGINT, SIGTERM]);
$this->logger->info(sprintf('Received signal %d, stopping server', $signal));
} catch (UnsupportedFeatureException $e) {
$this->logger->error($e->getMessage());
}
$this->stop();
} else {
if (!$this->isEventLoopStarted && !defined('PHPUNIT_COMPOSER_INSTALL')) {
$this->isEventLoopStarted = true;
EventLoop::run();
}
}
}
public function stop(): void {
foreach ($this->modules as $module) {
$module->stop();
}
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace OpenCCK\Infrastructure\API;
interface AppModuleInterface {
public function start(): void;
public function stop(): void;
}

View File

@@ -0,0 +1,17 @@
<?php
namespace OpenCCK\Infrastructure\API\Handler;
use Amp\Http\Server\ErrorHandler;
use Amp\Http\Server\Request;
use Amp\Http\Server\Response;
final class HTTPErrorHandler implements ErrorHandler {
public function handleError(int $status, ?string $reason = null, ?Request $request = null): Response {
return new Response(
status: $status,
headers: ['content-type' => 'application/json; charset=utf-8'],
body: json_encode(['message' => $reason, 'code' => $status])
);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace OpenCCK\Infrastructure\API\Handler;
use Amp\Http\Server\Request;
use Amp\Http\Server\RequestHandler;
use Amp\Http\Server\RequestHandler\ClosureRequestHandler;
use Amp\Http\Server\Response;
use Psr\Log\LoggerInterface;
use Throwable;
use function OpenCCK\getEnv;
final class HTTPHandler extends Handler implements HTTPHandlerInterface {
private function __construct(private readonly LoggerInterface $logger, array $headers = null) {
}
public static function getInstance(LoggerInterface $logger, array $headers = null): HTTPHandler {
return new self($logger, $headers);
}
/**
* @param string $controllerName
* @return RequestHandler
*/
public function getHandler(string $controllerName = 'main'): RequestHandler {
return new ClosureRequestHandler(function (Request $request) use ($controllerName): Response {
try {
$response = $this->getController(
ucfirst($request->getQueryParameter('format') ?: $controllerName),
$request,
$this->headers ?? []
)();
} catch (Throwable $e) {
$this->logger->warning('Exception', [
'exception' => $e::class,
'error' => $e->getMessage(),
'code' => $e->getCode(),
]);
$response = new Response(
status: $e->getCode() ?: 500,
headers: $this->headers ?? ['content-type' => 'application/json; charset=utf-8'],
body: json_encode(
array_merge(
['message' => $e->getMessage(), 'code' => $e->getCode()],
getEnv('DEBUG') === 'true'
? ['file' => $e->getFile() . ':' . $e->getLine(), 'trace' => $e->getTrace()]
: []
)
)
);
}
return $response;
});
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace OpenCCK\Infrastructure\API\Handler;
use Amp\Http\Server\RequestHandler;
interface HTTPHandlerInterface extends HandlerInterface {
/**
* @return RequestHandler
*/
public function getHandler(): RequestHandler;
}

View File

@@ -0,0 +1,27 @@
<?php
namespace OpenCCK\Infrastructure\API\Handler;
use Amp\Http\HttpStatus;
use Amp\Http\Server\Request;
use OpenCCK\App\Controller\AbstractController;
use Exception;
abstract class Handler implements HandlerInterface {
/**
* @param string $name
* @param Request $request
* @param ?string[] $headers
* @return AbstractController
* @throws Exception
*/
protected function getController(string $name, Request $request, array $headers = null): AbstractController {
$className = '\\OpenCCK\\App\\Controller\\' . ucfirst($name) . 'Controller';
if (!class_exists($className)) {
throw new Exception('Controller ' . $className . ' not found', HttpStatus::NOT_FOUND);
}
return new $className($request, $headers ?? ['content-type' => 'text/plain']);
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace OpenCCK\Infrastructure\API\Handler;
use Amp\Http\Server\RequestHandler;
interface HandlerInterface {
/**
* @return RequestHandler
*/
public function getHandler(): RequestHandler;
}

View File

@@ -0,0 +1,119 @@
<?php
namespace OpenCCK\Infrastructure\API;
use Amp\ByteStream\BufferException;
use Amp\Http\Server\DefaultErrorHandler;
use Amp\Http\Server\Driver\ConnectionLimitingClientFactory;
use Amp\Http\Server\Driver\ConnectionLimitingServerSocketFactory;
use Amp\Http\Server\Driver\DefaultHttpDriverFactory;
use Amp\Http\Server\Driver\SocketClientFactory;
use Amp\Http\Server\ErrorHandler;
use Amp\Http\Server\HttpServer;
use Amp\Http\Server\HttpServerStatus;
use Amp\Http\Server\SocketHttpServer;
use Amp\Socket\BindContext;
use Amp\Sync\LocalSemaphore;
use Amp\Socket;
use Monolog\Logger;
use OpenCCK\App\Service\IPListService;
use OpenCCK\Infrastructure\API\Handler\HTTPHandler;
use Throwable;
use function OpenCCK\getEnv;
final class Server implements AppModuleInterface {
private static Server $_instance;
private int $connectionLimit = 1024;
private int $connectionPerIpLimit = 10;
/**
* @param ?HttpServer $httpServer
* @param ?ErrorHandler $errorHandler
* @param ?BindContext $bindContext
* @param ?Logger $logger
* @throws Throwable
*/
private function __construct(
private ?HttpServer $httpServer,
private ?ErrorHandler $errorHandler,
private ?Socket\BindContext $bindContext,
private ?Logger $logger
) {
$this->logger = $logger ?? App::getLogger();
$serverSocketFactory = new ConnectionLimitingServerSocketFactory(new LocalSemaphore($this->connectionLimit));
$clientFactory = new ConnectionLimitingClientFactory(
new SocketClientFactory($this->logger),
$this->logger,
$this->connectionPerIpLimit
);
$this->httpServer =
$httpServer ??
new SocketHttpServer(
logger: $this->logger,
serverSocketFactory: $serverSocketFactory,
clientFactory: $clientFactory,
httpDriverFactory: new DefaultHttpDriverFactory(logger: $this->logger, streamTimeout: 60)
);
$this->bindContext = $bindContext ?? (new Socket\BindContext())->withoutTlsContext();
$this->errorHandler = $errorHandler ?? new DefaultErrorHandler();
// инициализация сервиса
IPListService::getInstance($this->logger);
}
/**
* @param ?HttpServer $httpServer
* @param ?ErrorHandler $errorHandler
* @param ?BindContext $bindContext
* @param ?Logger $logger
* @throws BufferException
* @throws Throwable
*/
public static function getInstance(
HttpServer $httpServer = null,
ErrorHandler $errorHandler = null,
Socket\BindContext $bindContext = null,
Logger $logger = null
): Server {
return self::$_instance ??= new self($httpServer, $errorHandler, $bindContext, $logger);
}
/**
* Запуск веб-сервера
* @return void
*/
public function start(): void {
try {
$this->httpServer->expose(
new Socket\InternetAddress(getEnv('HTTP_HOST') ?? '0.0.0.0', getEnv('HTTP_PORT') ?? 8080),
$this->bindContext
);
//$this->socketHttpServer->expose(
// new Socket\InternetAddress('[::]', $_ENV['HTTP_PORT'] ?? 8080),
// $this->bindContext
//);
$this->httpServer->start(HTTPHandler::getInstance($this->logger)->getHandler(), $this->errorHandler);
} catch (Socket\SocketException $e) {
$this->logger->warning($e->getMessage());
} catch (Throwable $e) {
$this->logger->error($e->getMessage());
}
}
/**
* @return void
*/
public function stop(): void {
$this->httpServer->stop();
}
/**
* @return HttpServerStatus
*/
public function getStatus(): HttpServerStatus {
return $this->httpServer->getStatus();
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace OpenCCK\Infrastructure\Storage;
use OpenCCK\Infrastructure\API\App;
use Revolt\EventLoop;
class CIDRStorage implements StorageInterface {
const FILENAME = 'cidr.json';
private static CIDRStorage $_instance;
private array $data = [];
private function __construct() {
$path = PATH_ROOT . '/storage/' . self::FILENAME;
if (is_file($path)) {
$this->data = (array) json_decode(file_get_contents($path)) ?? [];
}
EventLoop::repeat(\OpenCCK\getEnv('STORAGE_SAVE_INTERVAL') ?? 120, $this->save(...));
}
public static function getInstance(): CIDRStorage {
return self::$_instance ??= new self();
}
public function get(string $key): ?array {
return $this->data[$key] ?? null;
}
public function set(string $key, mixed $value): bool {
$this->data[$key] = $value;
return true;
}
public function has(string $key): bool {
return isset($this->data[$key]);
}
private function save(): void {
file_put_contents(PATH_ROOT . '/storage/' . self::FILENAME, json_encode($this->data, JSON_PRETTY_PRINT));
App::getLogger()->notice('Whois storage saved', [count($this->data) . ' items']);
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace OpenCCK\Infrastructure\Storage;
interface StorageInterface {
public function get(string $key): mixed;
public function set(string $key, mixed $value): bool;
public function has(string $key): bool;
}

View File

@@ -0,0 +1,20 @@
<?php
namespace OpenCCK\Infrastructure\Task;
use Amp\Cancellation;
use Amp\Sync\Channel;
readonly class ShellTask implements TaskInterface {
public function __construct(private string $command) {
}
/**
* @param Channel $channel
* @param Cancellation $cancellation
* @return string
*/
public function run(Channel $channel, Cancellation $cancellation): mixed {
return shell_exec($this->command);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace OpenCCK\Infrastructure\Task;
use Amp\Cancellation;
use Amp\Parallel\Worker\Task;
use Amp\Sync\Channel;
interface TaskInterface extends Task {
public function run(Channel $channel, Cancellation $cancellation): mixed;
}

33
src/functions.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
namespace OpenCCK;
/**
* @param string $key
* @return ?string
*/
function getEnv(string $key): ?string {
if (!isset($_ENV[$key])) {
return null;
}
if (preg_match('/^"(.*)"$/i', $_ENV[$key], $matches)) {
return $matches[1];
}
return $_ENV[$key];
}
/**
* Debug function
* @param mixed $mixed
* @param bool $exit
* @return void
*/
function dbg(mixed $mixed, bool $exit = true): void {
if (php_sapi_name() == 'cli') {
echo print_r($mixed, true);
} else {
echo '<pre>' . print_r($mixed, true) . '</pre>';
}
if ($exit) {
exit();
}
}