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); + } +}