diff --git a/README.md b/README.md
index 46fa774d..c718f299 100644
--- a/README.md
+++ b/README.md
@@ -322,6 +322,12 @@ packeton:
- '/data/hdd1/composer'
# Default path to storage/(local cache for S3) of uploaded artifacts
artifact_storage: '%composer_home_dir%/artifact_storage'
+
+ web_protection:
+ ## Multi host protection, disable web-ui if host !== app.example.com and ips != 127.0.0.1, 10.9.1.0/24
+ ## But the repo metadata will be available for all hosts and ips.
+ repo_hosts: ['*', '!app.example.com']
+ allow_ips: '127.0.0.1, 10.9.1.0/24'
```
### Metadata format.
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index 9f02f487..7daedffc 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -27,10 +27,12 @@
- [JWT Configuration](authentication-jwt.md)
- [LDAP Configuration](authentication-ldap.md)
- [S3 Storage Provider](usage/storage.md)
+ - [Custom landing page](usage/custom-page.md)
- [Security Monitoring](usage/security-monitoring.md)
- [OAuth2 Integrations](oauth2.md)
- [Pull Request review](pull-request-review.md)
- [GitHub Setup](oauth2/github-oauth.md)
+ - [GitHub AppBot](oauth2/githubapp.md)
- [GitLab Setup](oauth2/gitlab-integration.md)
- [Gitea Setup](oauth2/gitea.md)
- [Bitbucket Setup](oauth2/bitbucket.md)
diff --git a/docs/dev/configuration.md b/docs/dev/configuration.md
index 2b675207..cb34a054 100644
--- a/docs/dev/configuration.md
+++ b/docs/dev/configuration.md
@@ -105,4 +105,22 @@ packeton:
sync_interval: 3600 # default auto.
info_cmd_message: "\n\u001b[37;44m#Слава\u001b[30;43mУкраїні!\u001b[0m\n\u001b[40;31m#Смерть\u001b[30;41mворогам\u001b[0m" # Info message
+ web_protection:
+ ## Multi host protection, disable web-ui if host !== app.example.com and ips != 127.0.0.1, 10.9.1.0/24
+ ## But the repo metadata will be available for all hosts and ips.
+ repo_hosts: ['*', '!app.example.com']
+ allow_ips: '127.0.0.1, 10.9.1.0/24'
+ status_code: 402
+ custom_page: > # Custom landing non-auth page. Path or HTML
+
+
402 Payment Required
+
+ 402 Payment Required
+
nginx
+
+
+
+ web_protection:
+ ## Disable web-ui for host = repo.example.com
+ repo_hosts: ['repo.example.com']
```
diff --git a/docs/usage/custom-page.md b/docs/usage/custom-page.md
new file mode 100644
index 00000000..11f3339f
--- /dev/null
+++ b/docs/usage/custom-page.md
@@ -0,0 +1,40 @@
+# Custom landing page
+
+If you are distributing packages to your customers,
+you may want to create a separate domain for Composer metadata-only to hide
+the default web interface and login page.
+
+Add following lines to you configuration. `config.yaml or config/packages/*.yaml`
+
+```yaml
+packeton:
+ web_protection:
+ ## Multi host protection, disable web-ui if host !== app.example.com and ips != 127.0.0.1, 10.9.1.0/24
+ ## But the repo metadata will be available for all hosts and ips.
+ repo_hosts: ['*', '!app.example.com']
+ allow_ips: '127.0.0.1, 10.9.1.0/24'
+ status_code: 402
+ custom_page: > # Custom landing non-auth page. Path or HTML
+
+ 402 Payment Required
+
+ 402 Payment Required
+
nginx
+
+
+```
+
+Where `custom_page` html content or path to html page.
+
+Here all hosts will be hidden under this page (if ip is not match or host != app.example.com).
+
+`app.example.com` - this is host for default Web-UI.
+
+### Example 2
+
+```yaml
+ web_protection:
+ repo_hosts: ['repo.example.com']
+```
+
+Here Web-UI will be hidden for `repo_hosts` host `repo.example.com`.
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
index 43cdb2e9..68365ac1 100644
--- a/src/DependencyInjection/Configuration.php
+++ b/src/DependencyInjection/Configuration.php
@@ -42,6 +42,20 @@ public function getConfigTreeBuilder(): TreeBuilder
->end()
->booleanNode('anonymous_access')->defaultFalse()->end()
->booleanNode('anonymous_archive_access')->defaultFalse()->end()
+ ->arrayNode('web_protection')
+ ->addDefaultsIfNotSet()
+ ->children()
+ ->arrayNode('repo_hosts')
+ ->example(['repo.packagist.com', '*', '!app.packagist.com'])
+ ->scalarPrototype()->end()
+ ->end()
+ ->scalarNode('allow_ips')->end()
+ ->scalarNode('custom_page')->end()
+ ->integerNode('status_code')->end()
+ ->scalarNode('content_type')->end()
+ ->end()
+ ->end()
+
->booleanNode('archive')
->defaultFalse()
->end()
diff --git a/src/DependencyInjection/PacketonExtension.php b/src/DependencyInjection/PacketonExtension.php
index e8a0886d..3bc1f3a1 100644
--- a/src/DependencyInjection/PacketonExtension.php
+++ b/src/DependencyInjection/PacketonExtension.php
@@ -73,6 +73,7 @@ public function load(array $configs, ContainerBuilder $container): void
$container->setParameter('packeton_artifact_paths', $config['artifacts']['allowed_paths'] ?? []);
$container->setParameter('packeton_artifact_storage', $config['artifacts']['artifact_storage'] ?? null);
$container->setParameter('packeton_artifact_types', $config['artifacts']['support_types'] ?? []);
+ $container->setParameter('packeton_web_protection', $config['web_protection'] ?? null);
$container->registerAttributeForAutoconfiguration(AsWorker::class, static function (ChildDefinition $definition, AsWorker $attribute) {
$attributes = get_object_vars($attribute);
diff --git a/src/EventListener/ProtectHostListener.php b/src/EventListener/ProtectHostListener.php
new file mode 100644
index 00000000..0a5fa6f4
--- /dev/null
+++ b/src/EventListener/ProtectHostListener.php
@@ -0,0 +1,87 @@
+ 1,
+ 'root_providers' => 1,
+ 'metadata_changes' => 1,
+ 'root_package' => 1,
+ 'root_package_v2' => 1,
+ 'download_dist_package' => 1,
+ 'track_download' => 1,
+ 'track_download_batch' =>1,
+ 'root_packages_slug' =>1,
+ 'root_providers_slug' => 1,
+ 'root_package_slug' => 1,
+ 'root_package_v2_slug' => 1,
+ 'mirror_root' => 1,
+ 'mirror_metadata_v2' => 1,
+ 'mirror_metadata_v1' => 1,
+ 'mirror_zipball' => 1,
+ 'mirror_provider_includes' => 1,
+ ];
+
+ public function __construct(
+ #[Autowire(param: 'packeton_web_protection')]
+ protected ?array $protection = null
+ ) {
+ }
+
+ #[AsEventListener('kernel.request', priority: 30)]
+ public function onKernelRequest(RequestEvent $event): void
+ {
+ if (empty($this->protection)) {
+ return;
+ }
+
+ $request = $event->getRequest();
+ $route = $request->attributes->get('_route');
+ if (isset(static::$allowedRoutes[$route])) {
+ return;
+ }
+
+ if ($protectedHosts = ($this->protection['repo_hosts'] ?? [])) {
+ $host = $request->getHost();
+ if (in_array($host, $protectedHosts, true) || (in_array('*', $protectedHosts, true) && !in_array('!'.$host, $protectedHosts, true))) {
+ $this->terminate($event);
+ }
+ }
+
+ if ($allowIps = ($this->protection['allow_ips'] ?? null)) {
+ $allowIps = array_map('trim', explode(',', $allowIps));
+ if (false === IpUtils::checkIp($request->getClientIp() ?? '', $allowIps)) {
+ $this->terminate($event);
+ }
+ }
+ }
+
+ private function terminate(RequestEvent $event): void
+ {
+ $route = $event->getRequest()->attributes->get('_route');
+
+ $response = new JsonResponse(['error' => 'Not Found'], 404);
+ if ($route === 'home' && ($customPage = $this->protection['custom_page'] ?? null)) {
+ $customPage = is_file($customPage) ? file_get_contents($customPage) : $customPage;
+ $response = new Response($customPage, $this->protection['status_code'] ?? 200);
+ if ($contentType = $this->protection['content_type'] ?? null) {
+ $response->headers->set('content-type', $contentType);
+ }
+
+ $event->getRequest()->attributes->set('_format', 'X-Debug');
+ }
+
+ $event->setResponse($response);
+ }
+}