From a11f35232ff93f1e56afe44cd043d9c9f83ae4b0 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Thu, 16 Jul 2026 21:32:59 -0400 Subject: [PATCH] Move the container logic into providers so construction of the call graph is more focused. --- src/FreeDSx/Ldap/Container.php | 818 +----------------- .../Container/ClientContainerProvider.php | 95 ++ .../Container/ContainerProviderInterface.php | 29 + .../Container/CoreServerContainerProvider.php | 593 +++++++++++++ .../PasswordPolicyContainerProvider.php | 119 +++ src/FreeDSx/Ldap/ServerOptions.php | 10 +- tests/unit/ServerOptionsTest.php | 7 +- 7 files changed, 881 insertions(+), 790 deletions(-) create mode 100644 src/FreeDSx/Ldap/Container/ClientContainerProvider.php create mode 100644 src/FreeDSx/Ldap/Container/ContainerProviderInterface.php create mode 100644 src/FreeDSx/Ldap/Container/CoreServerContainerProvider.php create mode 100644 src/FreeDSx/Ldap/Container/PasswordPolicyContainerProvider.php diff --git a/src/FreeDSx/Ldap/Container.php b/src/FreeDSx/Ldap/Container.php index afdf7e87..f24b1e81 100644 --- a/src/FreeDSx/Ldap/Container.php +++ b/src/FreeDSx/Ldap/Container.php @@ -13,75 +13,15 @@ namespace FreeDSx\Ldap; -use Closure; +use FreeDSx\Ldap\Container\ClientContainerProvider; +use FreeDSx\Ldap\Container\ContainerProviderInterface; +use FreeDSx\Ldap\Container\CoreServerContainerProvider; +use FreeDSx\Ldap\Container\PasswordPolicyContainerProvider; use FreeDSx\Ldap\Exception\RuntimeException; -use FreeDSx\Ldap\Protocol\ClientProtocolHandler; -use FreeDSx\Ldap\Protocol\Factory\ClientProtocolHandlerFactory; -use FreeDSx\Ldap\Protocol\Factory\ServerProtocolHandlerFactory; -use FreeDSx\Ldap\Protocol\Queue\ClientQueueInstantiator; -use FreeDSx\Ldap\Protocol\RootDseLoader; use FreeDSx\Ldap\Protocol\ServerAuthorization; -use FreeDSx\Ldap\Server\Clock\ClockInterface; -use FreeDSx\Ldap\Server\Clock\SystemClock; -use FreeDSx\Ldap\Schema\SchemaValidationMode; -use FreeDSx\Ldap\Schema\Validation\SchemaValidator; -use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; -use FreeDSx\Ldap\Server\Backend\Storage\ReplicaPasswordStateStoreProviderInterface; -use FreeDSx\Ldap\Server\Clock\Sleeper\BlockingSleeper; -use FreeDSx\Ldap\Server\Clock\Sleeper\CoroutineSleeper; -use FreeDSx\Ldap\Server\PasswordPolicy\Replica\Forward\LdapClientForwardStateSender; -use FreeDSx\Ldap\Server\PasswordPolicy\Replica\Forward\PasswordPolicyForwardWorker; -use FreeDSx\Ldap\Server\Process\BackgroundTask\LongLivedTask; -use FreeDSx\Ldap\Server\Process\BackgroundTask\PcntlBackgroundTasks; -use FreeDSx\Ldap\Server\Process\BackgroundTask\PeriodicTask; -use FreeDSx\Ldap\Server\Process\BackgroundTask\SwooleBackgroundTasks; -use FreeDSx\Ldap\Server\Process\Signals\PcntlShutdownSignals; -use FreeDSx\Ldap\Sync\Consumer\LdapReplica; -use FreeDSx\Ldap\Sync\Consumer\PrimaryConnectionFactory; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture\ChangeJournalingInterface; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture\ChangeRecorder; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\RetentionPolicy; -use FreeDSx\Ldap\Server\Backend\Storage\Journal\RetentionSweeper; -use FreeDSx\Ldap\Server\Logging\EventLogger; -use FreeDSx\Ldap\Server\Backend\Storage\OperationalAttributeGenerator; -use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; -use Psr\Log\NullLogger; use FreeDSx\Ldap\Server\HandlerFactoryInterface; -use FreeDSx\Ldap\Server\Metrics\File\FileSnapshotProvider; -use FreeDSx\Ldap\Server\Metrics\File\FileSnapshotWriter; -use FreeDSx\Ldap\Server\Metrics\File\SnapshotPublisher; -use FreeDSx\Ldap\Server\Metrics\MetricsRecorderInterface; -use FreeDSx\Ldap\Server\Metrics\MetricsSnapshotProvider; -use FreeDSx\Ldap\Server\Metrics\Recorder\InMemoryMetricsRecorder; -use FreeDSx\Ldap\Server\Metrics\Recorder\MetricsRecorderChain; -use FreeDSx\Ldap\Server\Metrics\Recorder\NullMetricsRecorder; -use FreeDSx\Ldap\Server\Metrics\Rollup\OperationRollupCoordinator; -use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\AllowUserChangeConstraint; -use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\HistoryConstraint; -use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\MinAgeConstraint; -use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\PasswordChangeConstraintChain; -use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\QualityConstraint; -use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\SafeModifyConstraint; -use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyComponentFactory; -use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyEngine; -use FreeDSx\Ldap\Server\PasswordPolicy\UniquePolicyTimeFactory; -use FreeDSx\Ldap\Server\PasswordPolicy\Replica\InMemoryReplicaPasswordStateStore; -use FreeDSx\Ldap\Server\PasswordPolicy\Replica\ReplicaPasswordStateStoreInterface; -use FreeDSx\Ldap\Server\Backend\Auth\PasswordHashService; -use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; -use FreeDSx\Ldap\Server\PasswordModify\PasswordModifyTargetResolver; -use FreeDSx\Ldap\Server\RequestHandler\HandlerFactory; -use FreeDSx\Ldap\Server\Proxy\ProxyProtocolFactory; -use FreeDSx\Ldap\Server\ServerProtocolFactory; -use FreeDSx\Ldap\Server\ServerProtocolFactoryInterface; -use FreeDSx\Ldap\Server\ServerRunner\PcntlServerRunner; -use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface; -use FreeDSx\Ldap\Server\ServerRunner\SwooleServerRunner; -use FreeDSx\Ldap\Server\SocketServerFactory; -use FreeDSx\Socket\SocketOptions; -use FreeDSx\Socket\SocketPool; -use FreeDSx\Socket\SocketPoolOptions; -use FreeDSx\Socket\Transport; + +use function in_array; class Container { @@ -94,7 +34,7 @@ class Container ]; /** - * @var array + * @var array */ private array $instanceFactory = []; @@ -112,8 +52,14 @@ public function __construct(array $instances) $this->instances[$className] = $instance; } - $this->registerClientClasses(); - $this->registerServerClasses(); + foreach ($this->providers() as $provider) { + foreach ($provider->factories() as $className => $factory) { + $this->registerFactory( + $className, + $factory, + ); + } + } } /** @@ -134,7 +80,7 @@ public function get(string $className): object )); } - $instance = ($this->instanceFactory[$className])(); + $instance = ($this->instanceFactory[$className])($this); if (!$instance instanceof $className) { throw new RuntimeException(sprintf( 'The factory for "%s" did not return the expected type.', @@ -159,732 +105,34 @@ public function has(string $className): bool } /** - * @param class-string $className - */ - private function registerFactory( - string $className, - callable $factory, - ): void { - $this->instanceFactory[$className] = $factory; - } - - private function registerClientClasses(): void - { - if (!isset($this->instances[ClientOptions::class])) { - return; - } - - $this->registerFactory( - className: ClientProtocolHandler::class, - factory: $this->makeClientProtocolHandler(...), - ); - $this->registerFactory( - className: SocketPool::class, - factory: $this->makeSocketPool(...), - ); - $this->registerFactory( - className: ClientProtocolHandlerFactory::class, - factory: $this->makeClientProtocolHandlerFactory(...), - ); - $this->registerFactory( - className: ClientQueueInstantiator::class, - factory: $this->makeClientQueueInstantiator(...), - ); - $this->registerFactory( - className: RootDseLoader::class, - factory: $this->makeRootDseLoader(...), - ); - } - - private function registerServerClasses(): void - { - if (!isset($this->instances[ServerOptions::class])) { - return; - } - - $this->registerFactory( - className: SocketServerFactory::class, - factory: $this->makeSocketServerFactory(...), - ); - $this->registerFactory( - className: HandlerFactoryInterface::class, - factory: $this->makeHandlerFactory(...), - ); - $this->registerFactory( - className: WritableStorageBackend::class, - factory: $this->makeBackend(...), - ); - $this->registerFactory( - className: ServerProtocolFactory::class, - factory: $this->makeServerProtocolFactory(...), - ); - $this->registerFactory( - className: ServerProtocolFactoryInterface::class, - factory: $this->makeServerProtocolFactoryInterface(...), - ); - $this->registerFactory( - className: ServerRunnerInterface::class, - factory: $this->makeServerRunner(...), - ); - $this->registerFactory( - className: ServerAuthorization::class, - factory: $this->makeServerAuthorizer(...), - ); - $this->registerFactory( - className: ClockInterface::class, - factory: static fn(): ClockInterface => new SystemClock(), - ); - $this->registerFactory( - className: PasswordPolicyEngine::class, - factory: $this->makePasswordPolicyEngine(...), - ); - $this->registerFactory( - className: ReplicaPasswordStateStoreInterface::class, - factory: $this->makeReplicaPasswordStateStore(...), - ); - $this->registerFactory( - className: ServerProtocolHandlerFactory::class, - factory: $this->makeServerProtocolHandlerFactory(...), - ); - $this->registerFactory( - className: PasswordModifyTargetResolver::class, - factory: $this->makePasswordModifyTargetResolver(...), - ); - $this->registerFactory( - className: PasswordHashService::class, - factory: $this->makePasswordHashService(...), - ); - $this->registerFactory( - className: WriteOperationDispatcher::class, - factory: $this->makeWriteOperationDispatcher(...), - ); - $this->registerFactory( - className: PasswordPolicyComponentFactory::class, - factory: $this->makePasswordPolicyComponentFactory(...), - ); - $this->registerFactory( - className: InMemoryMetricsRecorder::class, - factory: static fn(): InMemoryMetricsRecorder => new InMemoryMetricsRecorder(), - ); - $this->registerFactory( - className: MetricsRecorderInterface::class, - factory: $this->makeMetricsRecorder(...), - ); - $this->registerFactory( - className: MetricsSnapshotProvider::class, - factory: $this->makeMetricsSnapshotProvider(...), - ); - $this->registerFactory( - className: OperationRollupCoordinator::class, - factory: fn(): OperationRollupCoordinator => new OperationRollupCoordinator( - $this->get(InMemoryMetricsRecorder::class), - ), - ); - } - - /** - * The process metrics recorder: an in-memory recorder when cn=monitor is enabled (chained with a user recorder if - * set), otherwise just the user recorder (a no-op by default). - */ - private function makeMetricsRecorder(): MetricsRecorderInterface - { - $options = $this->get(ServerOptions::class); - $userRecorder = $options->getMetricsRecorder(); - - if (!$options->isMonitorEnabled()) { - return $userRecorder; - } - - $inMemory = $this->get(InMemoryMetricsRecorder::class); - - if ($userRecorder instanceof NullMetricsRecorder) { - return $inMemory; - } - - return new MetricsRecorderChain( - $inMemory, - $userRecorder, - ); - } - - /** - * The snapshot source for cn=monitor: the live in-memory recorder under Swoole, or the parent-published file under - * the forking PCNTL runner. - */ - private function makeMetricsSnapshotProvider(): MetricsSnapshotProvider - { - $options = $this->get(ServerOptions::class); - - if (!$options->getUseSwooleRunner()) { - return new FileSnapshotProvider($this->monitorSnapshotPath($options)); - } - - return $this->get(InMemoryMetricsRecorder::class); - } - - private function monitorSnapshotPath(ServerOptions $options): string - { - return $options->getMonitorSnapshotPath() - ?? sys_get_temp_dir() . '/freedsx_ldap_monitor_' . $options->getPort() . '.json'; - } - - private function makePasswordPolicyEngine(): PasswordPolicyEngine - { - $options = $this->get(ServerOptions::class); - $clock = $this->get(ClockInterface::class); - - $chain = new PasswordChangeConstraintChain([ - new AllowUserChangeConstraint(), - new SafeModifyConstraint(), - new MinAgeConstraint($clock), - new QualityConstraint($options->getPasswordQualityChecker()), - new HistoryConstraint(new PasswordHashService()), - ]); - - return new PasswordPolicyEngine( - clock: $clock, - changeConstraints: $chain, - uniqueTimes: new UniquePolicyTimeFactory( - $clock, - $options->getChangeJournalConfig()->origin, - ), - ); - } - - private function makeClientProtocolHandler(): ClientProtocolHandler - { - return new ClientProtocolHandler( - options: $this->get(ClientOptions::class), - clientQueueInstantiator: $this->get(ClientQueueInstantiator::class), - protocolHandlerFactory: $this->get(ClientProtocolHandlerFactory::class), - ); - } - - private function makeClientQueueInstantiator(): ClientQueueInstantiator - { - return new ClientQueueInstantiator($this->get(SocketPool::class)); - } - - private function makeSocketPool(): SocketPool - { - $clientOptions = $this->get(ClientOptions::class); - $socketOptions = (new SocketOptions()) - ->setTransport(Transport::from($clientOptions->getTransport())) - ->setPort($clientOptions->getPort()) - ->setUseSsl($clientOptions->isUseSsl()) - ->setSslValidateCert($clientOptions->isSslValidateCert()) - ->setSslAllowSelfSigned($clientOptions->isSslAllowSelfSigned()) - ->setSslCaCert($clientOptions->getSslCaCert()) - ->setSslCert($clientOptions->getSslCert()) - ->setSslCertKey($clientOptions->getSslCertKey()) - ->setSslPeerName($clientOptions->getSslPeerName()) - ->setTimeoutConnect($clientOptions->getTimeoutConnect()) - ->setTimeoutRead($clientOptions->getTimeoutRead()); - - $poolOptions = (new SocketPoolOptions($socketOptions)) - ->setServers($clientOptions->getServers()); - - return new SocketPool($poolOptions); - } - - private function makeClientProtocolHandlerFactory(): ClientProtocolHandlerFactory - { - return new ClientProtocolHandlerFactory( - clientOptions: $this->get(ClientOptions::class), - queueInstantiator: $this->get(ClientQueueInstantiator::class), - rootDseLoader: $this->get(RootDseLoader::class), - ); - } - - private function makeRootDseLoader(): RootDseLoader - { - return new RootDseLoader($this->get(LdapClient::class)); - } - - private function makeServerProtocolFactory(): ServerProtocolFactory - { - return new ServerProtocolFactory( - handlerFactory: $this->get(HandlerFactoryInterface::class), - options: $this->get(ServerOptions::class), - passwordPolicyEngine: $this->get(PasswordPolicyEngine::class), - routeResolver: $this->get(ServerProtocolHandlerFactory::class), - targetResolver: $this->get(PasswordModifyTargetResolver::class), - hashService: $this->get(PasswordHashService::class), - writeDispatcher: $this->get(WriteOperationDispatcher::class), - policyComponentFactory: $this->get(PasswordPolicyComponentFactory::class), - metricsRecorder: $this->get(MetricsRecorderInterface::class), - metricsSnapshots: $this->get(MetricsSnapshotProvider::class), - operationRollup: $this->makeOperationRollup(), - replicaPasswordStateStore: $this->get(ServerOptions::class)->isReadOnly() - ? $this->get(ReplicaPasswordStateStoreInterface::class) - : null, - ); - } - - /** - * The replica-local password-policy state store, persisted by the storage backend when it can, else in memory. - */ - private function makeReplicaPasswordStateStore(): ReplicaPasswordStateStoreInterface - { - $storage = $this->get(ServerOptions::class)->getStorage(); - - return $storage instanceof ReplicaPasswordStateStoreProviderInterface - ? $storage->replicaPasswordStateStore() - : new InMemoryReplicaPasswordStateStore(); - } - - private function makeServerProtocolFactoryInterface(): ServerProtocolFactoryInterface - { - if ($this->has(ProxyOptions::class)) { - return new ProxyProtocolFactory( - $this->get(ServerOptions::class), - $this->get(ProxyOptions::class), - ); - } - - return $this->get(ServerProtocolFactory::class); - } - - private function makeServerProtocolHandlerFactory(): ServerProtocolHandlerFactory - { - return new ServerProtocolHandlerFactory($this->get(ServerOptions::class)); - } - - private function makePasswordModifyTargetResolver(): PasswordModifyTargetResolver - { - $handlerFactory = $this->get(HandlerFactoryInterface::class); - - return new PasswordModifyTargetResolver( - $handlerFactory->makeBackend(), - $handlerFactory->makeIdentityResolverChain(), - ); - } - - private function makePasswordHashService(): PasswordHashService - { - return new PasswordHashService($this->get(ServerOptions::class)->getPasswordHashScheme()); - } - - private function makeWriteOperationDispatcher(): WriteOperationDispatcher - { - return $this->get(HandlerFactoryInterface::class)->makeWriteDispatcher(); - } - - private function makePasswordPolicyComponentFactory(): PasswordPolicyComponentFactory - { - return new PasswordPolicyComponentFactory( - handlerFactory: $this->get(HandlerFactoryInterface::class), - options: $this->get(ServerOptions::class), - writeDispatcher: $this->get(WriteOperationDispatcher::class), - passwordPolicyEngine: $this->get(PasswordPolicyEngine::class), - ); - } - - private function makeHandlerFactory(): HandlerFactory - { - return new HandlerFactory( - $this->get(ServerOptions::class), - $this->backendOrFail(), - ); - } - - /** - * The configured backend; only reached on the non-proxy path, where LdapServer's startup check guarantees one. - */ - private function backendOrFail(): WritableStorageBackend - { - return $this->backendOrNull() - ?? throw new RuntimeException('No storage is configured; set ServerOptions::setStorage().'); - } - - /** - * The configured backend, or null when no storage is set (the proxy path has none). - */ - private function backendOrNull(): ?WritableStorageBackend - { - return $this->get(ServerOptions::class)->getStorage() !== null - ? $this->get(WritableStorageBackend::class) - : null; - } - - /** - * Assemble the writable backend from the configured storage; only invoked when storage is set. - */ - private function makeBackend(): WritableStorageBackend - { - $options = $this->get(ServerOptions::class); - $storage = $options->getStorage(); - - if ($storage === null) { - throw new RuntimeException('No storage is configured; set ServerOptions::setStorage().'); - } - - $schema = $options->getSchemaValidationMode() !== SchemaValidationMode::Off - ? $options->getSchema() - : null; - - return new WritableStorageBackend( - storage: $storage, - limits: $options->makeSearchLimits(), - validator: $this->buildSchemaValidator(), - operationalAttrs: new OperationalAttributeGenerator($schema), - changeRecorder: $this->changeRecorderFor($storage), - schema: $options->getSchema(), - ); - } - - private function buildSchemaValidator(): ?SchemaValidator - { - $options = $this->get(ServerOptions::class); - $mode = $options->getSchemaValidationMode(); - - if ($mode === SchemaValidationMode::Off) { - return null; - } - - return new SchemaValidator( - $options->getSchema(), - $mode, - ); - } - - /** - * Configure the storage's journal and return a recorder when sync is enabled and the storage can journal. - */ - private function changeRecorderFor(EntryStorageInterface $storage): ?ChangeRecorder - { - $options = $this->get(ServerOptions::class); - - if (!$options->isSyncEnabled() || !$storage instanceof ChangeJournalingInterface) { - return null; - } - - $storage->configureJournal($options->getChangeJournalConfig()); - - return new ChangeRecorder($options->getLogger() ?? new NullLogger()); - } - - private function makeServerRunner(): ServerRunnerInterface - { - $options = $this->get(ServerOptions::class); - $protocolFactoryProvider = $this->makeProtocolFactoryProvider(); - $metricsRecorder = $this->get(MetricsRecorderInterface::class); - - if ($options->getUseSwooleRunner()) { - return new SwooleServerRunner( - serverProtocolFactory: $protocolFactoryProvider($options), - options: $options, - socketServerFactory: $this->get(SocketServerFactory::class), - protocolFactoryProvider: $protocolFactoryProvider, - metricsRecorder: $metricsRecorder, - backgroundTasks: $this->makeSwooleBackgroundTasks(), - ); - } - - return new PcntlServerRunner( - serverProtocolFactory: $protocolFactoryProvider($options), - options: $options, - socketServerFactory: $this->get(SocketServerFactory::class), - protocolFactoryProvider: $protocolFactoryProvider, - metricsRecorder: $metricsRecorder, - snapshotPublisher: $this->makeSnapshotPublisher(), - operationRollup: $this->makeOperationRollup(), - backend: $this->backendOrNull(), - backgroundTasks: $this->makePcntlBackgroundTasks(), - ); - } - - /** - * The retention policy to sweep on, or null when journaling is off / has no limits. - */ - private function journalRetentionPolicyIfSweepable(): ?RetentionPolicy - { - $options = $this->get(ServerOptions::class); - $backend = $this->backendOrNull(); - - if ($backend === null || !$options->isSyncEnabled()) { - return null; - } - - $journal = $backend->changeJournal(); - - if ($journal === null) { - return null; - } - - $policy = $options->getChangeJournalConfig()->retention; - - return RetentionSweeper::isSweepable( - $policy, - $journal, - $options->getUseSwooleRunner(), - ) - ? $policy - : null; - } - - private function makeRetentionSweeper(): ?RetentionSweeper - { - $policy = $this->journalRetentionPolicyIfSweepable(); - - if ($policy === null) { - return null; - } - - // Safe to resolve now: a non-null policy means sync is enabled and the journal is configured. - $journal = $this->backendOrNull()?->changeJournal(); - - if ($journal === null) { - return null; - } - - $options = $this->get(ServerOptions::class); - - return new RetentionSweeper( - $journal, - $policy, - new EventLogger( - $options->getLogger(), - $options->getEventLogPolicy(), - ), - ); - } - - private function makeSwooleBackgroundTasks(): SwooleBackgroundTasks - { - $periodicTasks = []; - $sweeper = $this->makeRetentionSweeper(); - if ($sweeper !== null) { - $periodicTasks[] = new PeriodicTask( - RetentionSweeper::TASK_NAME, - RetentionSweeper::DEFAULT_INTERVAL_SECONDS, - static function () use ($sweeper): void { - $sweeper->sweep(); - }, - ); - } - - $longLivedTasks = []; - $daemon = $this->makeReplicaDaemon(hostManagedShutdown: true); - if ($daemon !== null) { - $longLivedTasks[] = new LongLivedTask( - LdapReplica::TASK_NAME, - $daemon->run(...), - $daemon->stop(...), - ); - } - $forwardWorker = $this->makeForwardWorker(useCoroutineSleeper: true); - if ($forwardWorker !== null) { - $longLivedTasks[] = new LongLivedTask( - PasswordPolicyForwardWorker::TASK_NAME, - $forwardWorker->run(...), - $forwardWorker->stop(...), - ); - } - - return new SwooleBackgroundTasks( - $periodicTasks, - $longLivedTasks, - $this->get(ServerOptions::class)->getLogger(), - ); - } - - private function makePcntlBackgroundTasks(): PcntlBackgroundTasks - { - $options = $this->get(ServerOptions::class); - - $periodicTasks = []; - if ($this->journalRetentionPolicyIfSweepable() !== null) { - $periodicTasks[] = new PeriodicTask( - RetentionSweeper::TASK_NAME, - RetentionSweeper::DEFAULT_INTERVAL_SECONDS, - function (): void { - $this->makeRetentionSweeper()?->sweep(); - }, - ); - } - - $longLivedTasks = []; - if ($options->getReplicaConfig() !== null) { - $longLivedTasks[] = new LongLivedTask( - LdapReplica::TASK_NAME, - function (): void { - $this->makeReplicaDaemon(hostManagedShutdown: false)?->run(); - }, - ); - } - if ($this->makeForwardWorker(useCoroutineSleeper: false) !== null) { - $longLivedTasks[] = new LongLivedTask( - PasswordPolicyForwardWorker::TASK_NAME, - function (): void { - $this->makeForwardWorker(useCoroutineSleeper: false)?->run(); - }, - ); - } - - return new PcntlBackgroundTasks( - periodicTasks: $periodicTasks, - longLivedTasks: $longLivedTasks, - logger: $options->getLogger(), - gracefulStopSeconds: $options->getShutdownTimeout(), - ); - } - - /** - * The replica password-policy forward worker when replica mode + password policy are configured, else null. - */ - private function makeForwardWorker(bool $useCoroutineSleeper): ?PasswordPolicyForwardWorker - { - $options = $this->get(ServerOptions::class); - $config = $options->getReplicaConfig(); - - if ($config === null || !$options->isPasswordPolicyEnabled()) { - return null; - } - - return new PasswordPolicyForwardWorker( - $this->get(ReplicaPasswordStateStoreInterface::class), - new LdapClientForwardStateSender(new PrimaryConnectionFactory($config)), - $useCoroutineSleeper - ? new CoroutineSleeper() - : new BlockingSleeper(), - signals: $useCoroutineSleeper - ? null - : new PcntlShutdownSignals(), - logger: $options->getLogger(), - ); - } - - /** - * The replica sync daemon when replica mode is configured, else null. + * The providers whose factories apply to the seeded options (client and/or server). * - * @param bool $hostManagedShutdown true under Swoole (the runner calls stop()), false under PCNTL (the forked child owns its signals) + * @return ContainerProviderInterface[] */ - private function makeReplicaDaemon(bool $hostManagedShutdown): ?LdapReplica + private function providers(): array { - $options = $this->get(ServerOptions::class); - $config = $options->getReplicaConfig(); - $storage = $options->getStorage(); - - if ($config === null || $storage === null) { - return null; - } - - // Pair reconciliation with forwarding: the store drops forwarded state once the primary's entry replicates back. - $passwordStateStore = $options->isPasswordPolicyEnabled() - ? $this->get(ReplicaPasswordStateStoreInterface::class) - : null; + $providers = []; - return $hostManagedShutdown - ? LdapReplica::forSwoole( - $config, - $storage, - $options->getLogger(), - signals: null, - passwordStateStore: $passwordStateStore, - ) - : LdapReplica::forPcntl( - $config, - $storage, - $options->getLogger(), - passwordStateStore: $passwordStateStore, - ); - } - - /** - * The child-to-parent operation rollup for the forking runner, over the shared in-memory recorder; built only when - * cn=monitor is enabled. - */ - private function makeOperationRollup(): ?OperationRollupCoordinator - { - if (!$this->get(ServerOptions::class)->isMonitorEnabled()) { - return null; + if (isset($this->instances[ClientOptions::class])) { + $providers[] = new ClientContainerProvider(); } - return $this->get(OperationRollupCoordinator::class); - } - - /** - * The PCNTL parent publishes connection metrics to a file for forked children (serving cn=monitor) to read; built - * only when cn=monitor is enabled. - */ - private function makeSnapshotPublisher(): ?SnapshotPublisher - { - $options = $this->get(ServerOptions::class); - - if (!$options->isMonitorEnabled()) { - return null; + if (isset($this->instances[ServerOptions::class])) { + $providers[] = new CoreServerContainerProvider(); + $providers[] = new PasswordPolicyContainerProvider(); } - return new SnapshotPublisher( - $this->get(InMemoryMetricsRecorder::class), - new FileSnapshotWriter($this->monitorSnapshotPath($options)), - ); + return $providers; } /** - * Builds a protocol factory from a (possibly reloaded) set of options via a fresh container. - * - * @return Closure(ServerOptions): ServerProtocolFactoryInterface + * @param class-string $className + * @param callable(self): object $factory */ - private function makeProtocolFactoryProvider(): Closure - { - $proxyOptions = $this->has(ProxyOptions::class) - ? $this->get(ProxyOptions::class) - : null; - // Carry the backend across reloads so a SIGHUP does not drop the configured storage. - $backend = $this->backendOrNull(); - - // Share the metrics state across reloads so SIGHUP does not reset the counters or detach cn=monitor. The rollup - // coordinator is shared so a reloaded middleware streams to the same channel the (persistent) runner bound. - $metricsRecorder = $this->get(MetricsRecorderInterface::class); - $metricsSnapshots = $this->get(MetricsSnapshotProvider::class); - $inMemoryMetrics = $this->get(InMemoryMetricsRecorder::class); - $operationRollup = $this->makeOperationRollup(); - - return static function (ServerOptions $options) use ( - $proxyOptions, - $backend, - $metricsRecorder, - $metricsSnapshots, - $inMemoryMetrics, - $operationRollup, - ): ServerProtocolFactoryInterface { - $instances = [ - ServerOptions::class => $options, - MetricsRecorderInterface::class => $metricsRecorder, - MetricsSnapshotProvider::class => $metricsSnapshots, - InMemoryMetricsRecorder::class => $inMemoryMetrics, - ]; - - if ($backend !== null) { - $instances[WritableStorageBackend::class] = $backend; - } - - if ($proxyOptions !== null) { - $instances[ProxyOptions::class] = $proxyOptions; - } - - if ($operationRollup !== null) { - $instances[OperationRollupCoordinator::class] = $operationRollup; - } - - return (new Container($instances))->get(ServerProtocolFactoryInterface::class); - }; - } - - private function makeSocketServerFactory(): SocketServerFactory - { - $serverOptions = $this->get(ServerOptions::class); - - return new SocketServerFactory( - options: $serverOptions, - logger: $serverOptions->getLogger(), - ); - } - - private function makeServerAuthorizer(): ServerAuthorization - { - return new ServerAuthorization($this->get(ServerOptions::class)); + private function registerFactory( + string $className, + callable $factory, + ): void { + $this->instanceFactory[$className] = $factory; } } diff --git a/src/FreeDSx/Ldap/Container/ClientContainerProvider.php b/src/FreeDSx/Ldap/Container/ClientContainerProvider.php new file mode 100644 index 00000000..9733da6d --- /dev/null +++ b/src/FreeDSx/Ldap/Container/ClientContainerProvider.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Container; + +use FreeDSx\Ldap\ClientOptions; +use FreeDSx\Ldap\Container; +use FreeDSx\Ldap\LdapClient; +use FreeDSx\Ldap\Protocol\ClientProtocolHandler; +use FreeDSx\Ldap\Protocol\Factory\ClientProtocolHandlerFactory; +use FreeDSx\Ldap\Protocol\Queue\ClientQueueInstantiator; +use FreeDSx\Ldap\Protocol\RootDseLoader; +use FreeDSx\Socket\SocketOptions; +use FreeDSx\Socket\SocketPool; +use FreeDSx\Socket\SocketPoolOptions; +use FreeDSx\Socket\Transport; + +/** + * Registers the client-side protocol and socket services. + * + * @author Chad Sikorra + */ +final class ClientContainerProvider implements ContainerProviderInterface +{ + public function factories(): array + { + return [ + ClientProtocolHandler::class => $this->makeClientProtocolHandler(...), + SocketPool::class => $this->makeSocketPool(...), + ClientProtocolHandlerFactory::class => $this->makeClientProtocolHandlerFactory(...), + ClientQueueInstantiator::class => $this->makeClientQueueInstantiator(...), + RootDseLoader::class => $this->makeRootDseLoader(...), + ]; + } + + private function makeClientProtocolHandler(Container $container): ClientProtocolHandler + { + return new ClientProtocolHandler( + options: $container->get(ClientOptions::class), + clientQueueInstantiator: $container->get(ClientQueueInstantiator::class), + protocolHandlerFactory: $container->get(ClientProtocolHandlerFactory::class), + ); + } + + private function makeClientQueueInstantiator(Container $container): ClientQueueInstantiator + { + return new ClientQueueInstantiator($container->get(SocketPool::class)); + } + + private function makeSocketPool(Container $container): SocketPool + { + $clientOptions = $container->get(ClientOptions::class); + $socketOptions = (new SocketOptions()) + ->setTransport(Transport::from($clientOptions->getTransport())) + ->setPort($clientOptions->getPort()) + ->setUseSsl($clientOptions->isUseSsl()) + ->setSslValidateCert($clientOptions->isSslValidateCert()) + ->setSslAllowSelfSigned($clientOptions->isSslAllowSelfSigned()) + ->setSslCaCert($clientOptions->getSslCaCert()) + ->setSslCert($clientOptions->getSslCert()) + ->setSslCertKey($clientOptions->getSslCertKey()) + ->setSslPeerName($clientOptions->getSslPeerName()) + ->setTimeoutConnect($clientOptions->getTimeoutConnect()) + ->setTimeoutRead($clientOptions->getTimeoutRead()); + + $poolOptions = (new SocketPoolOptions($socketOptions)) + ->setServers($clientOptions->getServers()); + + return new SocketPool($poolOptions); + } + + private function makeClientProtocolHandlerFactory(Container $container): ClientProtocolHandlerFactory + { + return new ClientProtocolHandlerFactory( + clientOptions: $container->get(ClientOptions::class), + queueInstantiator: $container->get(ClientQueueInstantiator::class), + rootDseLoader: $container->get(RootDseLoader::class), + ); + } + + private function makeRootDseLoader(Container $container): RootDseLoader + { + return new RootDseLoader($container->get(LdapClient::class)); + } +} diff --git a/src/FreeDSx/Ldap/Container/ContainerProviderInterface.php b/src/FreeDSx/Ldap/Container/ContainerProviderInterface.php new file mode 100644 index 00000000..2fec0335 --- /dev/null +++ b/src/FreeDSx/Ldap/Container/ContainerProviderInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Container; + +use FreeDSx\Ldap\Container; + +/** + * Supplies a focused set of container factories, merged into the container at construction. + * + * @author Chad Sikorra + */ +interface ContainerProviderInterface +{ + /** + * @return array factory map keyed by the class each factory produces. + */ + public function factories(): array; +} diff --git a/src/FreeDSx/Ldap/Container/CoreServerContainerProvider.php b/src/FreeDSx/Ldap/Container/CoreServerContainerProvider.php new file mode 100644 index 00000000..f48dd43a --- /dev/null +++ b/src/FreeDSx/Ldap/Container/CoreServerContainerProvider.php @@ -0,0 +1,593 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Container; + +use Closure; +use FreeDSx\Ldap\Container; +use FreeDSx\Ldap\Exception\RuntimeException; +use FreeDSx\Ldap\Protocol\Factory\ServerProtocolHandlerFactory; +use FreeDSx\Ldap\Protocol\ServerAuthorization; +use FreeDSx\Ldap\ProxyOptions; +use FreeDSx\Ldap\Schema\SchemaValidationMode; +use FreeDSx\Ldap\Schema\Validation\SchemaValidator; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordHashService; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture\ChangeJournalingInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Capture\ChangeRecorder; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\RetentionPolicy; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\RetentionSweeper; +use FreeDSx\Ldap\Server\Backend\Storage\OperationalAttributeGenerator; +use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; +use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; +use FreeDSx\Ldap\Server\Clock\ClockInterface; +use FreeDSx\Ldap\Server\Clock\Sleeper\BlockingSleeper; +use FreeDSx\Ldap\Server\Clock\Sleeper\CoroutineSleeper; +use FreeDSx\Ldap\Server\Clock\SystemClock; +use FreeDSx\Ldap\Server\HandlerFactoryInterface; +use FreeDSx\Ldap\Server\Logging\EventLogger; +use FreeDSx\Ldap\Server\Metrics\File\FileSnapshotProvider; +use FreeDSx\Ldap\Server\Metrics\File\FileSnapshotWriter; +use FreeDSx\Ldap\Server\Metrics\File\SnapshotPublisher; +use FreeDSx\Ldap\Server\Metrics\MetricsRecorderInterface; +use FreeDSx\Ldap\Server\Metrics\MetricsSnapshotProvider; +use FreeDSx\Ldap\Server\Metrics\Recorder\InMemoryMetricsRecorder; +use FreeDSx\Ldap\Server\Metrics\Recorder\MetricsRecorderChain; +use FreeDSx\Ldap\Server\Metrics\Recorder\NullMetricsRecorder; +use FreeDSx\Ldap\Server\Metrics\Rollup\OperationRollupCoordinator; +use FreeDSx\Ldap\Server\PasswordModify\PasswordModifyTargetResolver; +use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyComponentFactory; +use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyEngine; +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\Forward\LdapClientForwardStateSender; +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\Forward\PasswordPolicyForwardWorker; +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\ReplicaPasswordStateStoreInterface; +use FreeDSx\Ldap\Server\Process\BackgroundTask\LongLivedTask; +use FreeDSx\Ldap\Server\Process\BackgroundTask\PcntlBackgroundTasks; +use FreeDSx\Ldap\Server\Process\BackgroundTask\PeriodicTask; +use FreeDSx\Ldap\Server\Process\BackgroundTask\SwooleBackgroundTasks; +use FreeDSx\Ldap\Server\Process\Signals\PcntlShutdownSignals; +use FreeDSx\Ldap\Server\Proxy\ProxyProtocolFactory; +use FreeDSx\Ldap\Server\RequestHandler\HandlerFactory; +use FreeDSx\Ldap\Server\ServerProtocolFactory; +use FreeDSx\Ldap\Server\ServerProtocolFactoryInterface; +use FreeDSx\Ldap\Server\ServerRunner\PcntlServerRunner; +use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface; +use FreeDSx\Ldap\Server\ServerRunner\SwooleServerRunner; +use FreeDSx\Ldap\Server\SocketServerFactory; +use FreeDSx\Ldap\ServerOptions; +use FreeDSx\Ldap\Sync\Consumer\LdapReplica; +use FreeDSx\Ldap\Sync\Consumer\PrimaryConnectionFactory; +use Psr\Log\NullLogger; + +/** + * Registers the core server services: sockets, backend, protocol factory, runner, metrics, and clock. + * + * @author Chad Sikorra + */ +final class CoreServerContainerProvider implements ContainerProviderInterface +{ + public function factories(): array + { + return [ + SocketServerFactory::class => $this->makeSocketServerFactory(...), + HandlerFactoryInterface::class => $this->makeHandlerFactory(...), + WritableStorageBackend::class => $this->makeBackend(...), + ServerProtocolFactory::class => $this->makeServerProtocolFactory(...), + ServerProtocolFactoryInterface::class => $this->makeServerProtocolFactoryInterface(...), + ServerRunnerInterface::class => $this->makeServerRunner(...), + ServerAuthorization::class => $this->makeServerAuthorizer(...), + ClockInterface::class => static fn(): ClockInterface => new SystemClock(), + ServerProtocolHandlerFactory::class => $this->makeServerProtocolHandlerFactory(...), + InMemoryMetricsRecorder::class => static fn(): InMemoryMetricsRecorder => new InMemoryMetricsRecorder(), + MetricsRecorderInterface::class => $this->makeMetricsRecorder(...), + MetricsSnapshotProvider::class => $this->makeMetricsSnapshotProvider(...), + OperationRollupCoordinator::class => $this->makeOperationRollupCoordinator(...), + ]; + } + + private function makeSocketServerFactory(Container $container): SocketServerFactory + { + $serverOptions = $container->get(ServerOptions::class); + + return new SocketServerFactory( + options: $serverOptions, + logger: $serverOptions->getLogger(), + ); + } + + private function makeServerAuthorizer(Container $container): ServerAuthorization + { + return new ServerAuthorization($container->get(ServerOptions::class)); + } + + private function makeServerProtocolHandlerFactory(Container $container): ServerProtocolHandlerFactory + { + return new ServerProtocolHandlerFactory($container->get(ServerOptions::class)); + } + + private function makeHandlerFactory(Container $container): HandlerFactory + { + return new HandlerFactory( + $container->get(ServerOptions::class), + $this->backendOrFail($container), + ); + } + + /** + * The configured backend; only reached on the non-proxy path, where LdapServer's startup check guarantees one. + */ + private function backendOrFail(Container $container): WritableStorageBackend + { + return $this->backendOrNull($container) + ?? throw new RuntimeException('No storage is configured; set ServerOptions::setStorage().'); + } + + /** + * The configured backend, or null when no storage is set (the proxy path has none). + */ + private function backendOrNull(Container $container): ?WritableStorageBackend + { + return $container->get(ServerOptions::class)->getStorage() !== null + ? $container->get(WritableStorageBackend::class) + : null; + } + + /** + * Assemble the writable backend from the configured storage; only invoked when storage is set. + */ + private function makeBackend(Container $container): WritableStorageBackend + { + $options = $container->get(ServerOptions::class); + $storage = $options->getStorage(); + + if ($storage === null) { + throw new RuntimeException('No storage is configured; set ServerOptions::setStorage().'); + } + + $schema = $options->getSchemaValidationMode() !== SchemaValidationMode::Off + ? $options->getSchema() + : null; + + return new WritableStorageBackend( + storage: $storage, + limits: $options->makeSearchLimits(), + validator: $this->buildSchemaValidator($container), + operationalAttrs: new OperationalAttributeGenerator($schema), + changeRecorder: $this->changeRecorderFor($container, $storage), + schema: $options->getSchema(), + ); + } + + private function buildSchemaValidator(Container $container): ?SchemaValidator + { + $options = $container->get(ServerOptions::class); + $mode = $options->getSchemaValidationMode(); + + if ($mode === SchemaValidationMode::Off) { + return null; + } + + return new SchemaValidator( + $options->getSchema(), + $mode, + ); + } + + /** + * Configure the storage's journal and return a recorder when sync is enabled and the storage can journal. + */ + private function changeRecorderFor( + Container $container, + EntryStorageInterface $storage, + ): ?ChangeRecorder { + $options = $container->get(ServerOptions::class); + + if (!$options->isSyncEnabled() || !$storage instanceof ChangeJournalingInterface) { + return null; + } + + $storage->configureJournal($options->getChangeJournalConfig()); + + return new ChangeRecorder($options->getLogger() ?? new NullLogger()); + } + + private function makeServerProtocolFactory(Container $container): ServerProtocolFactory + { + return new ServerProtocolFactory( + handlerFactory: $container->get(HandlerFactoryInterface::class), + options: $container->get(ServerOptions::class), + passwordPolicyEngine: $container->get(PasswordPolicyEngine::class), + routeResolver: $container->get(ServerProtocolHandlerFactory::class), + targetResolver: $container->get(PasswordModifyTargetResolver::class), + hashService: $container->get(PasswordHashService::class), + writeDispatcher: $container->get(WriteOperationDispatcher::class), + policyComponentFactory: $container->get(PasswordPolicyComponentFactory::class), + metricsRecorder: $container->get(MetricsRecorderInterface::class), + metricsSnapshots: $container->get(MetricsSnapshotProvider::class), + operationRollup: $this->makeOperationRollup($container), + replicaPasswordStateStore: $container->get(ServerOptions::class)->isReadOnly() + ? $container->get(ReplicaPasswordStateStoreInterface::class) + : null, + ); + } + + private function makeServerProtocolFactoryInterface(Container $container): ServerProtocolFactoryInterface + { + if ($container->has(ProxyOptions::class)) { + return new ProxyProtocolFactory( + $container->get(ServerOptions::class), + $container->get(ProxyOptions::class), + ); + } + + return $container->get(ServerProtocolFactory::class); + } + + private function makeServerRunner(Container $container): ServerRunnerInterface + { + $options = $container->get(ServerOptions::class); + $protocolFactoryProvider = $this->makeProtocolFactoryProvider($container); + $metricsRecorder = $container->get(MetricsRecorderInterface::class); + + if ($options->getUseSwooleRunner()) { + return new SwooleServerRunner( + serverProtocolFactory: $protocolFactoryProvider($options), + options: $options, + socketServerFactory: $container->get(SocketServerFactory::class), + protocolFactoryProvider: $protocolFactoryProvider, + metricsRecorder: $metricsRecorder, + backgroundTasks: $this->makeSwooleBackgroundTasks($container), + ); + } + + return new PcntlServerRunner( + serverProtocolFactory: $protocolFactoryProvider($options), + options: $options, + socketServerFactory: $container->get(SocketServerFactory::class), + protocolFactoryProvider: $protocolFactoryProvider, + metricsRecorder: $metricsRecorder, + snapshotPublisher: $this->makeSnapshotPublisher($container), + operationRollup: $this->makeOperationRollup($container), + backend: $this->backendOrNull($container), + backgroundTasks: $this->makePcntlBackgroundTasks($container), + ); + } + + /** + * The retention policy to sweep on, or null when journaling is off / has no limits. + */ + private function journalRetentionPolicyIfSweepable(Container $container): ?RetentionPolicy + { + $options = $container->get(ServerOptions::class); + $backend = $this->backendOrNull($container); + + if ($backend === null || !$options->isSyncEnabled()) { + return null; + } + + $journal = $backend->changeJournal(); + + if ($journal === null) { + return null; + } + + $policy = $options->getChangeJournalConfig()->retention; + + return RetentionSweeper::isSweepable( + $policy, + $journal, + $options->getUseSwooleRunner(), + ) + ? $policy + : null; + } + + private function makeRetentionSweeper(Container $container): ?RetentionSweeper + { + $policy = $this->journalRetentionPolicyIfSweepable($container); + + if ($policy === null) { + return null; + } + + // Safe to resolve now: a non-null policy means sync is enabled and the journal is configured. + $journal = $this->backendOrNull($container)?->changeJournal(); + + if ($journal === null) { + return null; + } + + $options = $container->get(ServerOptions::class); + + return new RetentionSweeper( + $journal, + $policy, + new EventLogger( + $options->getLogger(), + $options->getEventLogPolicy(), + ), + ); + } + + private function makeSwooleBackgroundTasks(Container $container): SwooleBackgroundTasks + { + $periodicTasks = []; + $sweeper = $this->makeRetentionSweeper($container); + if ($sweeper !== null) { + $periodicTasks[] = new PeriodicTask( + RetentionSweeper::TASK_NAME, + RetentionSweeper::DEFAULT_INTERVAL_SECONDS, + static function () use ($sweeper): void { + $sweeper->sweep(); + }, + ); + } + + $longLivedTasks = []; + $daemon = $this->makeReplicaDaemon($container, hostManagedShutdown: true); + if ($daemon !== null) { + $longLivedTasks[] = new LongLivedTask( + LdapReplica::TASK_NAME, + $daemon->run(...), + $daemon->stop(...), + ); + } + $forwardWorker = $this->makeForwardWorker($container, useCoroutineSleeper: true); + if ($forwardWorker !== null) { + $longLivedTasks[] = new LongLivedTask( + PasswordPolicyForwardWorker::TASK_NAME, + $forwardWorker->run(...), + $forwardWorker->stop(...), + ); + } + + return new SwooleBackgroundTasks( + $periodicTasks, + $longLivedTasks, + $container->get(ServerOptions::class)->getLogger(), + ); + } + + private function makePcntlBackgroundTasks(Container $container): PcntlBackgroundTasks + { + $options = $container->get(ServerOptions::class); + + $periodicTasks = []; + if ($this->journalRetentionPolicyIfSweepable($container) !== null) { + $periodicTasks[] = new PeriodicTask( + RetentionSweeper::TASK_NAME, + RetentionSweeper::DEFAULT_INTERVAL_SECONDS, + function () use ($container): void { + $this->makeRetentionSweeper($container)?->sweep(); + }, + ); + } + + $longLivedTasks = []; + if ($options->getReplicaConfig() !== null) { + $longLivedTasks[] = new LongLivedTask( + LdapReplica::TASK_NAME, + function () use ($container): void { + $this->makeReplicaDaemon($container, hostManagedShutdown: false)?->run(); + }, + ); + } + if ($this->makeForwardWorker($container, useCoroutineSleeper: false) !== null) { + $longLivedTasks[] = new LongLivedTask( + PasswordPolicyForwardWorker::TASK_NAME, + function () use ($container): void { + $this->makeForwardWorker($container, useCoroutineSleeper: false)?->run(); + }, + ); + } + + return new PcntlBackgroundTasks( + periodicTasks: $periodicTasks, + longLivedTasks: $longLivedTasks, + logger: $options->getLogger(), + gracefulStopSeconds: $options->getShutdownTimeout(), + ); + } + + /** + * The replica password-policy forward worker when replica mode + password policy are configured, else null. + */ + private function makeForwardWorker( + Container $container, + bool $useCoroutineSleeper, + ): ?PasswordPolicyForwardWorker { + $options = $container->get(ServerOptions::class); + $config = $options->getReplicaConfig(); + + if ($config === null || !$options->isPasswordPolicyEnabled()) { + return null; + } + + return new PasswordPolicyForwardWorker( + $container->get(ReplicaPasswordStateStoreInterface::class), + new LdapClientForwardStateSender(new PrimaryConnectionFactory($config)), + $useCoroutineSleeper + ? new CoroutineSleeper() + : new BlockingSleeper(), + signals: $useCoroutineSleeper + ? null + : new PcntlShutdownSignals(), + logger: $options->getLogger(), + ); + } + + /** + * The replica sync daemon when replica mode is configured, else null. + * + * @param bool $hostManagedShutdown true under Swoole (the runner calls stop()), false under PCNTL (the forked child owns its signals) + */ + private function makeReplicaDaemon( + Container $container, + bool $hostManagedShutdown, + ): ?LdapReplica { + $options = $container->get(ServerOptions::class); + $config = $options->getReplicaConfig(); + $storage = $options->getStorage(); + + if ($config === null || $storage === null) { + return null; + } + + // Pair reconciliation with forwarding: the store drops forwarded state once the primary's entry replicates back. + $passwordStateStore = $options->isPasswordPolicyEnabled() + ? $container->get(ReplicaPasswordStateStoreInterface::class) + : null; + + return $hostManagedShutdown + ? LdapReplica::forSwoole( + $config, + $storage, + $options->getLogger(), + signals: null, + passwordStateStore: $passwordStateStore, + ) + : LdapReplica::forPcntl( + $config, + $storage, + $options->getLogger(), + passwordStateStore: $passwordStateStore, + ); + } + + /** + * The child-to-parent operation rollup for the forking runner, over the shared in-memory recorder; built only when + * cn=monitor is enabled. + */ + private function makeOperationRollup(Container $container): ?OperationRollupCoordinator + { + if (!$container->get(ServerOptions::class)->isMonitorEnabled()) { + return null; + } + + return $container->get(OperationRollupCoordinator::class); + } + + private function makeOperationRollupCoordinator(Container $container): OperationRollupCoordinator + { + return new OperationRollupCoordinator($container->get(InMemoryMetricsRecorder::class)); + } + + /** + * The PCNTL parent publishes connection metrics to a file for forked children (serving cn=monitor) to read; built + * only when cn=monitor is enabled. + */ + private function makeSnapshotPublisher(Container $container): ?SnapshotPublisher + { + $options = $container->get(ServerOptions::class); + + if (!$options->isMonitorEnabled()) { + return null; + } + + return new SnapshotPublisher( + $container->get(InMemoryMetricsRecorder::class), + new FileSnapshotWriter($options->getMonitorSnapshotPath()), + ); + } + + /** + * The process metrics recorder: an in-memory recorder when cn=monitor is enabled (chained with a user recorder if + * set), otherwise just the user recorder (a no-op by default). + */ + private function makeMetricsRecorder(Container $container): MetricsRecorderInterface + { + $options = $container->get(ServerOptions::class); + $userRecorder = $options->getMetricsRecorder(); + + if (!$options->isMonitorEnabled()) { + return $userRecorder; + } + + $inMemory = $container->get(InMemoryMetricsRecorder::class); + + if ($userRecorder instanceof NullMetricsRecorder) { + return $inMemory; + } + + return new MetricsRecorderChain( + $inMemory, + $userRecorder, + ); + } + + /** + * The snapshot source for cn=monitor: the live in-memory recorder under Swoole, or the parent-published file under + * the forking PCNTL runner. + */ + private function makeMetricsSnapshotProvider(Container $container): MetricsSnapshotProvider + { + $options = $container->get(ServerOptions::class); + + if (!$options->getUseSwooleRunner()) { + return new FileSnapshotProvider($options->getMonitorSnapshotPath()); + } + + return $container->get(InMemoryMetricsRecorder::class); + } + + /** + * Builds a protocol factory from a (possibly reloaded) set of options via a fresh container. + * + * @return Closure(ServerOptions): ServerProtocolFactoryInterface + */ + private function makeProtocolFactoryProvider(Container $container): Closure + { + $proxyOptions = $container->has(ProxyOptions::class) + ? $container->get(ProxyOptions::class) + : null; + // Carry the backend across reloads so a SIGHUP does not drop the configured storage. + $backend = $this->backendOrNull($container); + + // Share the metrics state across reloads so SIGHUP does not reset the counters or detach cn=monitor. The rollup + // coordinator is shared so a reloaded middleware streams to the same channel the (persistent) runner bound. + $metricsRecorder = $container->get(MetricsRecorderInterface::class); + $metricsSnapshots = $container->get(MetricsSnapshotProvider::class); + $inMemoryMetrics = $container->get(InMemoryMetricsRecorder::class); + $operationRollup = $this->makeOperationRollup($container); + + return static function (ServerOptions $options) use ( + $proxyOptions, + $backend, + $metricsRecorder, + $metricsSnapshots, + $inMemoryMetrics, + $operationRollup, + ): ServerProtocolFactoryInterface { + $instances = [ + ServerOptions::class => $options, + MetricsRecorderInterface::class => $metricsRecorder, + MetricsSnapshotProvider::class => $metricsSnapshots, + InMemoryMetricsRecorder::class => $inMemoryMetrics, + ]; + + if ($backend !== null) { + $instances[WritableStorageBackend::class] = $backend; + } + + if ($proxyOptions !== null) { + $instances[ProxyOptions::class] = $proxyOptions; + } + + if ($operationRollup !== null) { + $instances[OperationRollupCoordinator::class] = $operationRollup; + } + + return (new Container($instances))->get(ServerProtocolFactoryInterface::class); + }; + } +} diff --git a/src/FreeDSx/Ldap/Container/PasswordPolicyContainerProvider.php b/src/FreeDSx/Ldap/Container/PasswordPolicyContainerProvider.php new file mode 100644 index 00000000..7f9d7075 --- /dev/null +++ b/src/FreeDSx/Ldap/Container/PasswordPolicyContainerProvider.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Container; + +use FreeDSx\Ldap\Container; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordHashService; +use FreeDSx\Ldap\Server\Backend\Storage\ReplicaPasswordStateStoreProviderInterface; +use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; +use FreeDSx\Ldap\Server\Clock\ClockInterface; +use FreeDSx\Ldap\Server\HandlerFactoryInterface; +use FreeDSx\Ldap\Server\PasswordModify\PasswordModifyTargetResolver; +use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\AllowUserChangeConstraint; +use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\HistoryConstraint; +use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\MinAgeConstraint; +use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\PasswordChangeConstraintChain; +use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\QualityConstraint; +use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\SafeModifyConstraint; +use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyComponentFactory; +use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyEngine; +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\InMemoryReplicaPasswordStateStore; +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\ReplicaPasswordStateStoreInterface; +use FreeDSx\Ldap\Server\PasswordPolicy\UniquePolicyTimeFactory; +use FreeDSx\Ldap\ServerOptions; + +/** + * Registers the password-policy engine and its write/replica collaborators. + * + * @author Chad Sikorra + */ +final class PasswordPolicyContainerProvider implements ContainerProviderInterface +{ + public function factories(): array + { + return [ + PasswordPolicyEngine::class => $this->makePasswordPolicyEngine(...), + ReplicaPasswordStateStoreInterface::class => $this->makeReplicaPasswordStateStore(...), + PasswordModifyTargetResolver::class => $this->makePasswordModifyTargetResolver(...), + PasswordHashService::class => $this->makePasswordHashService(...), + WriteOperationDispatcher::class => $this->makeWriteOperationDispatcher(...), + PasswordPolicyComponentFactory::class => $this->makePasswordPolicyComponentFactory(...), + ]; + } + + private function makePasswordPolicyEngine(Container $container): PasswordPolicyEngine + { + $options = $container->get(ServerOptions::class); + $clock = $container->get(ClockInterface::class); + + $chain = new PasswordChangeConstraintChain([ + new AllowUserChangeConstraint(), + new SafeModifyConstraint(), + new MinAgeConstraint($clock), + new QualityConstraint($options->getPasswordQualityChecker()), + new HistoryConstraint(new PasswordHashService()), + ]); + + return new PasswordPolicyEngine( + clock: $clock, + changeConstraints: $chain, + uniqueTimes: new UniquePolicyTimeFactory( + $clock, + $options->getChangeJournalConfig()->origin, + ), + ); + } + + /** + * The replica-local password-policy state store, persisted by the storage backend when it can, else in memory. + */ + private function makeReplicaPasswordStateStore(Container $container): ReplicaPasswordStateStoreInterface + { + $storage = $container->get(ServerOptions::class)->getStorage(); + + return $storage instanceof ReplicaPasswordStateStoreProviderInterface + ? $storage->replicaPasswordStateStore() + : new InMemoryReplicaPasswordStateStore(); + } + + private function makePasswordModifyTargetResolver(Container $container): PasswordModifyTargetResolver + { + $handlerFactory = $container->get(HandlerFactoryInterface::class); + + return new PasswordModifyTargetResolver( + $handlerFactory->makeBackend(), + $handlerFactory->makeIdentityResolverChain(), + ); + } + + private function makePasswordHashService(Container $container): PasswordHashService + { + return new PasswordHashService($container->get(ServerOptions::class)->getPasswordHashScheme()); + } + + private function makeWriteOperationDispatcher(Container $container): WriteOperationDispatcher + { + return $container->get(HandlerFactoryInterface::class)->makeWriteDispatcher(); + } + + private function makePasswordPolicyComponentFactory(Container $container): PasswordPolicyComponentFactory + { + return new PasswordPolicyComponentFactory( + handlerFactory: $container->get(HandlerFactoryInterface::class), + options: $container->get(ServerOptions::class), + writeDispatcher: $container->get(WriteOperationDispatcher::class), + passwordPolicyEngine: $container->get(PasswordPolicyEngine::class), + ); + } +} diff --git a/src/FreeDSx/Ldap/ServerOptions.php b/src/FreeDSx/Ldap/ServerOptions.php index bc0a32a9..22508f63 100644 --- a/src/FreeDSx/Ldap/ServerOptions.php +++ b/src/FreeDSx/Ldap/ServerOptions.php @@ -982,9 +982,13 @@ public function isReadOnly(): bool return $this->replicaConfig !== null; } - public function getMonitorSnapshotPath(): ?string + /** + * The configured cn=monitor snapshot path, or a per-port default under the system temp directory. + */ + public function getMonitorSnapshotPath(): string { - return $this->monitorSnapshotPath; + return $this->monitorSnapshotPath + ?? sys_get_temp_dir() . '/freedsx_ldap_monitor_' . $this->port . '.json'; } public function setMonitorSnapshotPath(?string $monitorSnapshotPath): self @@ -1190,7 +1194,7 @@ public function toArray(): array 'ssl_allow_self_signed' => $this->getSslAllowSelfSigned(), 'ssl_ca_cert' => $this->getSslCaCert(), 'monitor_enabled' => $this->isMonitorEnabled(), - 'monitor_snapshot_path' => $this->getMonitorSnapshotPath(), + 'monitor_snapshot_path' => $this->monitorSnapshotPath, 'dse_alt_server' => $this->getDseAltServer(), 'dse_vendor_name' => $this->getDseVendorName(), 'dse_vendor_version' => $this->getDseVendorVersion(), diff --git a/tests/unit/ServerOptionsTest.php b/tests/unit/ServerOptionsTest.php index b5e76111..7759b6b1 100644 --- a/tests/unit/ServerOptionsTest.php +++ b/tests/unit/ServerOptionsTest.php @@ -171,9 +171,12 @@ public function test_it_can_enable_the_monitor(): void self::assertTrue($this->subject->isMonitorEnabled()); } - public function test_monitor_snapshot_path_is_null_by_default(): void + public function test_monitor_snapshot_path_defaults_to_a_per_port_temp_file(): void { - self::assertNull($this->subject->getMonitorSnapshotPath()); + self::assertSame( + sys_get_temp_dir() . '/freedsx_ldap_monitor_389.json', + $this->subject->getMonitorSnapshotPath(), + ); } public function test_sync_is_disabled_by_default(): void