├── .github └── workflows │ ├── check-cs.yml │ ├── phpstan.yml │ └── unit-tests.yml ├── .gitignore ├── .php-cs-fixer.php ├── CHANGELOG.md ├── LICENSE ├── README.md ├── UPGRADE.md ├── bin └── xdebug-proxy ├── composer.json ├── composer.lock ├── config ├── config.php ├── factory.php └── logger.php ├── grumphp.yml ├── phpstan.neon ├── phpunit.xml ├── softMocksConfig ├── config.php ├── factory.php └── logger.php ├── src ├── Config │ ├── Config.php │ ├── IdeServer.php │ ├── Server.php │ ├── SoftMocks.php │ └── SoftMocksConfig.php ├── Enum │ ├── RegistrationCommand.php │ └── RegistrationError.php ├── Factory │ ├── DefaultFactory.php │ ├── Factory.php │ └── SoftMocksFactory.php ├── Handler │ ├── CommandToXdebugParser.php │ ├── DefaultIdeHandler.php │ ├── DefaultXdebugHandler.php │ ├── Exception.php │ ├── FromXdebugProcessError.php │ ├── FromXdebugProcessException.php │ ├── Handler.php │ ├── IdeHandler.php │ ├── IdeRegistrationException.php │ └── XdebugHandler.php ├── LoggerFormatter.php ├── Proxy.php ├── RequestPreparer │ ├── Error.php │ ├── Exception.php │ ├── RequestPreparer.php │ └── SoftMocksRequestPreparer.php ├── RunError.php ├── Runner.php └── Xml │ ├── DomXmlConverter.php │ ├── XmlContainer.php │ ├── XmlConverter.php │ ├── XmlDocument.php │ ├── XmlException.php │ ├── XmlParseException.php │ └── XmlValidateException.php └── tests ├── TestCase.php ├── Unit └── Xml │ └── DomXmlConverterTest.php └── bootstrap.php /.github/workflows/check-cs.yml: -------------------------------------------------------------------------------- 1 | name: Check coding standards 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | php-cs-fixer: 7 | name: Check coding standards 8 | runs-on: ubuntu-24.04 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v2 12 | 13 | - name: Setup PHP 14 | uses: shivammathur/setup-php@v2 15 | with: 16 | coverage: "none" 17 | ini-values: memory_limit=-1,apc.enable_cli=1,zend.assertions=1 18 | php-version: "8.1" 19 | tools: flex 20 | 21 | - name: Install dependencies 22 | run: composer install 23 | 24 | - name: PHP CS Fixer 25 | run: vendor/bin/php-cs-fixer --allow-risky=yes --config=.php-cs-fixer.php --verbose fix --dry-run --diff 26 | -------------------------------------------------------------------------------- /.github/workflows/phpstan.yml: -------------------------------------------------------------------------------- 1 | name: PHPStan Static analysis 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | psalm: 7 | name: PHPStan 8 | runs-on: ubuntu-24.04 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v2 12 | 13 | - name: Setup PHP 14 | uses: shivammathur/setup-php@v2 15 | with: 16 | coverage: "none" 17 | ini-values: memory_limit=-1,apc.enable_cli=1,zend.assertions=1 18 | php-version: "8.1" 19 | tools: flex 20 | 21 | - name: Install dependencies 22 | run: composer install 23 | 24 | - name: "Restore result cache" 25 | uses: actions/cache/restore@v4 26 | with: 27 | path: tmp # same as in phpstan.neon 28 | key: "phpstan-result-cache-${{ github.run_id }}" 29 | restore-keys: | 30 | phpstan-result-cache- 31 | 32 | - name: "Run PHPStan" 33 | run: "vendor/bin/phpstan" 34 | 35 | - name: "Save result cache" 36 | uses: actions/cache/save@v4 37 | if: always() 38 | with: 39 | path: tmp # same as in phpstan.neon 40 | key: "phpstan-result-cache-${{ github.run_id }}" 41 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | 3 | on: [push, pull_request] 4 | 5 | defaults: 6 | run: 7 | shell: bash 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | tests: 14 | name: Tests 15 | 16 | strategy: 17 | matrix: 18 | include: 19 | - php: '8.4' 20 | - php: '8.3' 21 | - php: '8.2' 22 | - php: '8.1' 23 | fail-fast: false 24 | 25 | runs-on: ubuntu-24.04 26 | 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v2 30 | 31 | - name: Setup PHP 32 | uses: shivammathur/setup-php@v2 33 | with: 34 | coverage: "none" 35 | ini-values: memory_limit=-1,apc.enable_cli=1,zend.assertions=1 36 | php-version: "${{ matrix.php }}" 37 | tools: flex 38 | 39 | - name: Install dependencies 40 | run: composer install 41 | 42 | - name: Run tests 43 | run: vendor/bin/phpunit --colors=always 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /.php-cs-fixer.cache 3 | /.phpunit.result.cache 4 | /tmp 5 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | setRiskyAllowed(true) 10 | ->setRules([ 11 | '@Symfony' => true, 12 | 'concat_space' => ['spacing' => 'one'], 13 | 'global_namespace_import' => ['import_classes' => true, 'import_constants' => true, 'import_functions' => true], 14 | 'declare_strict_types' => true, 15 | 'ordered_imports' => [ 16 | 'imports_order' => [ 17 | 'class', 18 | 'const', 19 | 'function', 20 | ], 21 | ], 22 | 'strict_comparison' => true, 23 | 'combine_consecutive_unsets' => true, 24 | 'dir_constant' => true, 25 | 'ereg_to_preg' => true, 26 | 'modernize_types_casting' => true, 27 | 'multiline_whitespace_before_semicolons' => false, 28 | 'no_php4_constructor' => true, 29 | 'no_useless_else' => true, 30 | 'no_useless_return' => true, 31 | 'phpdoc_add_missing_param_annotation' => true, 32 | 'phpdoc_order' => ['order' => ['param', 'return', 'throws']], 33 | 'strict_param' => true, 34 | 'phpdoc_to_comment' => false, 35 | 'phpdoc_align' => false, 36 | 'yoda_style' => false, 37 | 'increment_style' => false, 38 | 'phpdoc_no_empty_return' => false, 39 | 'single_line_throw' => false, 40 | 'blank_line_before_statement' => false, 41 | 'phpdoc_separation' => [ 42 | 'groups' => [ 43 | ['Annotation', 'NamedArgumentConstructor', 'Target'], 44 | ['author', 'copyright', 'license'], 45 | ['category', 'package', 'subpackage'], 46 | ['property', 'property-read', 'property-write'], 47 | ['deprecated', 'link', 'see', 'since'], 48 | ['return', 'phpstan-return', 'param', 'phpstan-param'], 49 | ], 50 | ], 51 | ]) 52 | ->setFinder( 53 | Finder::create() 54 | ->in(__DIR__) 55 | ->exclude(['tmp', 'vendor']) 56 | ) 57 | ; 58 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## PHP xdebug (dbgp) proxy ChangeLog 2 | 3 | ### [Unreleased] 4 | 5 | There are next changes: 6 | 7 | ### [v0.6.0] 8 | 9 | There are next changes: 10 | - Update minimum php version from 7.4 to 8.1 ([#44](https://github.com/mougrim/php-xdebug-proxy/pull/44)) 11 | - Remove psalm, now phpstan is used ([#44](https://github.com/mougrim/php-xdebug-proxy/pull/44)) 12 | 13 | ### [v0.5.1] 14 | 15 | There are next changes: 16 | 17 | - Bump actions/download-artifact from 2 to 4.1.7 in /.github/workflows ([#41](https://github.com/mougrim/php-xdebug-proxy/pull/41)) 18 | - Bump symfony/process from 5.4.3 to 5.4.46 ([#42](https://github.com/mougrim/php-xdebug-proxy/pull/42)) 19 | 20 | ### [v0.5.0] 21 | 22 | There are next changes: 23 | 24 | - Minimum PHP version supported upgraded to 7.4 ([#29](https://github.com/mougrim/php-xdebug-proxy/pull/29)) 25 | - PHPUnit version upgraded to 9 ([#29](https://github.com/mougrim/php-xdebug-proxy/pull/29)) 26 | - Add run unit tests Github action ([#30](https://github.com/mougrim/php-xdebug-proxy/pull/30), [#31](https://github.com/mougrim/php-xdebug-proxy/pull/31)) 27 | - Remove travis ([#30](https://github.com/mougrim/php-xdebug-proxy/pull/30)) 28 | - Fix code style issues ([#32](https://github.com/mougrim/php-xdebug-proxy/pull/32)) 29 | - Fix by workaround grumphp issue with TypeError, see [phpro/grumphp#957](https://github.com/phpro/grumphp/issues/957) ([#32](https://github.com/mougrim/php-xdebug-proxy/pull/32)) 30 | - Update friendsofphp/php-cs-fixer to 3 ([#33](https://github.com/mougrim/php-xdebug-proxy/pull/33)) 31 | - Add psalm ([#34](https://github.com/mougrim/php-xdebug-proxy/pull/34), [#35](https://github.com/mougrim/php-xdebug-proxy/pull/35)) 32 | - Add check cs github action ([#36](https://github.com/mougrim/php-xdebug-proxy/pull/36)) 33 | 34 | ### [v0.4.1] 35 | 36 | There are next changes: 37 | 38 | - Fix changes in grumphp and bad composer.lock ([#26](https://github.com/mougrim/php-xdebug-proxy/pull/26)) 39 | 40 | ### [v0.4.0] 41 | 42 | There are next changes: 43 | 44 | - next methods were added ([#21](https://github.com/mougrim/php-xdebug-proxy/pull/21)): 45 | - `\Mougrim\XdebugProxy\Xml\XmlDocument::toArray()` 46 | - `\Mougrim\XdebugProxy\Xml\XmlContainer::toArray()` 47 | - `\Mougrim\XdebugProxy\Xml\XmlContainer::getAttribute()` 48 | - [config](softMocksConfig) for soft-mocks was added ([#22](https://github.com/mougrim/php-xdebug-proxy/pull/22)) 49 | - parameter $config was added to method `\Mougrim\XdebugProxy\Factory\Factory::createRequestPreparers()` ([#22](https://github.com/mougrim/php-xdebug-proxy/pull/22)) 50 | - now `\Mougrim\XdebugProxy\Factory\SoftMocksFactory::createConfig()` should return `\Mougrim\XdebugProxy\Config\SoftMocksConfig` ([#22](https://github.com/mougrim/php-xdebug-proxy/pull/22)) 51 | 52 | ### [v0.3.0] 53 | 54 | There are next changes: 55 | 56 | - there were code style fixes ([#14](https://github.com/mougrim/php-xdebug-proxy/pull/14)) 57 | - more info about default IDE in config was added to README.md ([#15](https://github.com/mougrim/php-xdebug-proxy/pull/15)) 58 | - now request preparers are called on request to xdebug from last to first ([#16](https://github.com/mougrim/php-xdebug-proxy/pull/16)) 59 | - minimum php version now is 7.1 ([#18](https://github.com/mougrim/php-xdebug-proxy/pull/18)) 60 | - constants visibility was added ([#18](https://github.com/mougrim/php-xdebug-proxy/pull/18)) 61 | - deprecated interface \Mougrim\XdebugProxy\RequestPreparer was removed ([#18](https://github.com/mougrim/php-xdebug-proxy/pull/18)) 62 | 63 | ### [v0.2.1] 64 | 65 | There are next changes: 66 | 67 | - possibility to disable IDE registration was added ([#12](https://github.com/mougrim/php-xdebug-proxy/pull/12)) 68 | 69 | ### [v0.2.0] 70 | 71 | There are next changes: 72 | 73 | - defaultIde config param was added ([#8](https://github.com/mougrim/php-xdebug-proxy/pull/8)) 74 | - predefinedIdeList config param was added ([#8](https://github.com/mougrim/php-xdebug-proxy/pull/8)) 75 | - now by default debug and info logs are disabled ([#9](https://github.com/mougrim/php-xdebug-proxy/pull/9)) 76 | - log levels for some logs are changed ([#9](https://github.com/mougrim/php-xdebug-proxy/pull/9)) 77 | - [README.md](README.md) was updated ([#11](https://github.com/mougrim/php-xdebug-proxy/pull/11)) 78 | - doc for [`RequestPreparer\RequestPreparer`](src/RequestPreparer/RequestPreparer.php) was added ([#11](https://github.com/mougrim/php-xdebug-proxy/pull/11)) 79 | 80 | ### [v0.1.0] 81 | 82 | There are next changes: 83 | 84 | - [soft-mocks](https://github.com/badoo/soft-mocks/#using-with-xdebug) support was added ([#7](https://github.com/mougrim/php-xdebug-proxy/pull/7)) 85 | - `\Mougrim\XdebugProxy\Handler\CommandToXdebugParser::buildCommand()` method was added ([#7](https://github.com/mougrim/php-xdebug-proxy/pull/7)) 86 | - now `\Mougrim\XdebugProxy\RequestPreparer\RequestPreparer` is used instead of `\Mougrim\XdebugProxy\RequestPreparer` ([#7](https://github.com/mougrim/php-xdebug-proxy/pull/7)) 87 | - `\Mougrim\XdebugProxy\RequestPreparer\RequestPreparer` methods can throw `\Mougrim\XdebugProxy\RequestPreparer\Error` and `\Mougrim\XdebugProxy\RequestPreparer\Exception` if there is some problem ([#7](https://github.com/mougrim/php-xdebug-proxy/pull/7)) 88 | - now `\Mougrim\XdebugProxy\Factory\Factory::createRequestPreparers()` accepts `$logger` param and also can throw `\Mougrim\XdebugProxy\RequestPreparer\Error` and `\Mougrim\XdebugProxy\RequestPreparer\Exception` if there is some problem ([#7](https://github.com/mougrim/php-xdebug-proxy/pull/7)) 89 | 90 | [unreleased]: https://github.com/mougrim/php-xdebug-proxy/compare/0.6.0...HEAD 91 | [v0.6.0]: https://github.com/mougrim/php-xdebug-proxy/compare/0.5.1...0.6.0 92 | [v0.5.1]: https://github.com/mougrim/php-xdebug-proxy/compare/0.5.0...0.5.1 93 | [v0.5.0]: https://github.com/mougrim/php-xdebug-proxy/compare/0.4.1...0.5.0 94 | [v0.4.1]: https://github.com/mougrim/php-xdebug-proxy/compare/0.4.0...0.4.1 95 | [v0.4.0]: https://github.com/mougrim/php-xdebug-proxy/compare/0.3.0...0.4.0 96 | [v0.3.0]: https://github.com/mougrim/php-xdebug-proxy/compare/0.2.1...0.3.0 97 | [v0.2.1]: https://github.com/mougrim/php-xdebug-proxy/compare/0.2.0...0.2.1 98 | [v0.2.0]: https://github.com/mougrim/php-xdebug-proxy/compare/0.1.0...0.2.0 99 | [v0.1.0]: https://github.com/mougrim/php-xdebug-proxy/compare/0.0.1...0.1.0 100 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Mougrim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## PHP xdebug (dbgp) proxy 2 | 3 | This is [expandable](#extending) [dbgp](https://xdebug.org/docs-dbgp.php) xdebug proxy based on [amphp](https://amphp.org/). 4 | 5 | The idea is described in the document [Multi-user debugging in PhpStorm with Xdebug and DBGp proxy](https://confluence.jetbrains.com/display/PhpStorm/Multi-user+debugging+in+PhpStorm+with+Xdebug+and+DBGp+proxy#Multi-userdebugginginPhpStormwithXdebugandDBGpproxy-HowdoesXdebugwork%3F). 6 | 7 | The main benefit is that this proxy is written in php - the language you know. 8 | 9 | [![Latest Stable Version](https://poser.pugx.org/mougrim/php-xdebug-proxy/version)](https://packagist.org/packages/mougrim/php-xdebug-proxy) 10 | [![Latest Unstable Version](https://poser.pugx.org/mougrim/php-xdebug-proxy/v/unstable)](https://packagist.org/packages/mougrim/php-xdebug-proxy) 11 | [![License](https://poser.pugx.org/mougrim/php-xdebug-proxy/license)](https://packagist.org/packages/mougrim/php-xdebug-proxy) 12 | [![Unit tests status](https://github.com/mougrim/php-xdebug-proxy/actions/workflows/unit-tests.yml/badge.svg?branch=master&event=push)](https://github.com/mougrim/php-xdebug-proxy/actions?query=branch%3Amaster) 13 | 14 | ### Installation 15 | 16 | This package can be installed as a [Composer](https://getcomposer.org/) project: 17 | 18 | ```bash 19 | composer.phar create-project mougrim/php-xdebug-proxy 20 | ``` 21 | 22 | Or dependency: 23 | 24 | ```bash 25 | composer.phar require mougrim/php-xdebug-proxy --dev 26 | ``` 27 | 28 | For parse XML you should install `ext-dom`. 29 | 30 | For write logs by default you should install `amphp/log` (use `--dev` if you installed `php-xdebug-proxy` as dependency): 31 | 32 | ```bash 33 | composer.phar require amphp/log '^1.0.0' 34 | ``` 35 | 36 | 37 | ### Run 38 | 39 | You can run next command: 40 | ```bash 41 | bin/xdebug-proxy 42 | ``` 43 | 44 | The proxy will be run with default config: 45 | ```text 46 | Using config path /path/to/php-xdebug-proxy/config 47 | [2019-02-14 10:46:24] xdebug-proxy.NOTICE: Use default ide: 127.0.0.1:9000 array ( ) array ( ) 48 | [2019-02-14 10:46:24] xdebug-proxy.NOTICE: Use predefined ides array ( 'predefinedIdeList' => array ( 'idekey' => '127.0.0.1:9000', ), ) array ( ) 49 | [2019-02-14 10:46:24] xdebug-proxy.NOTICE: [Proxy][IdeRegistration] Listening for new connections on '127.0.0.1:9001'... array ( ) array ( ) 50 | [2019-02-14 10:46:24] xdebug-proxy.NOTICE: [Proxy][Xdebug] Listening for new connections on '127.0.0.1:9002'... array ( ) array ( ) 51 | ``` 52 | 53 | So by default proxy listens `127.0.0.1:9001` for ide registration connections and `127.0.0.1:9002` for xdebug connections, use `127.0.0.1:9000` as default IDE and predefined IDE with key `idekey`. 54 | 55 | ### Config 56 | 57 | If you want to configure listening ports, etc., you can use custom config path. Just copy [`config`](config) directory to your custom path: 58 | 59 | ```bash 60 | cp -r /path/to/php-xdebug-proxy/config /your/custom/path 61 | ``` 62 | 63 | There are 3 files: 64 | 65 | - [`config.php`](config/config.php): 66 | ```php 67 | [ 70 | // xdebug proxy server host:port 71 | 'listen' => '127.0.0.1:9002', 72 | ], 73 | 'ideServer' => [ 74 | // if proxy can't find ide, then it uses default ide, 75 | // pass empty string if you want to disable default ide 76 | // defaultIde is useful when there is only one user for proxy 77 | 'defaultIde' => '127.0.0.1:9000', 78 | // predefined ide list in format 'idekey' => 'host:port', 79 | // pass empty array if you don't need predefined ide list 80 | // predefinedIdeList is useful when proxy's users aren't changed often, 81 | // so they don't need to register in proxy each proxy restart 82 | 'predefinedIdeList' => [ 83 | 'idekey' => '127.0.0.1:9000', 84 | ], 85 | ], 86 | 'ideRegistrationServer' => [ 87 | // host:port for register ide in proxy 88 | // pass empty string if you want to disable ide registration 89 | 'listen' => '127.0.0.1:9001', 90 | ], 91 | ]; 92 | ``` 93 | - [`logger.php`](config/logger.php): you can customize a logger, the file should return an object, which is instance of `\Psr\Log\LoggerInterface`; 94 | - [`factory.php`](config/factory.php): you can [customize classes](#extending), which are used in proxy, file should return object, which is instanceof [`Factory\Factory`](src/Factory/Factory.php). 95 | 96 | Then change configs and run: 97 | 98 | ```bash 99 | bin/xdebug-proxy --configs=/your/custom/path/config 100 | ``` 101 | 102 | ### Extending 103 | 104 | As mentioned [above](#factory-php) you can customize classes using your custom factory, which implements [`Factory\Factory`](src/Factory/Factory.php). By default [`Factory\DefaultFactory`](src/Factory/DefaultFactory.php) factory is used. 105 | 106 | The most powerful are the request preparers. You can override `Factory\DefaultFactory::createRequestPreparers()`. It should return an array of objects which implement [`RequestPreparer\RequestPreparer`](src/RequestPreparer/RequestPreparer.php) interface. 107 | 108 | Request preparers will be called: 109 | - on request to ide from first to last 110 | - on request to xdebug from last to first 111 | 112 | You can use request preparer for example for changing path to files (in break points and execution files). 113 | 114 | Good example of the request preparer is [`RequestPreparer\SoftMocksRequestPreparer`](src/RequestPreparer/SoftMocksRequestPreparer.php). You can see its usage in [`Factory\SoftMocksFactory`](src/Factory/SoftMocksFactory.php). 115 | 116 | ### Using with soft-mocks 117 | 118 | For soft-mocks you can use [`softMocksConfig`](softMocksConfig) config directory: 119 | ```bash 120 | bin/xdebug-proxy --configs=/path/to/php-xdebug-proxy/softMocksConfig 121 | ``` 122 | 123 | If you you want to provide path to custom `soft-mocks` init script, then copy [`softMocksConfig`](softMocksConfig) and change [`config.php`](softMocksConfig/config.php): 124 | ```php 125 | ... 126 | 'softMocks' => [ 127 | // if empty string, then vendor/badoo/soft-mocks/src/init_with_composer.php is used 128 | 'initScript' => '/your/custom/init-script.php', 129 | ], 130 | ... 131 | ``` 132 | 133 | For more information see doc in [soft-mocks](https://github.com/badoo/soft-mocks/#using-with-xdebug) project. 134 | 135 | ### Thanks 136 | 137 | Many thanks to [Eelf](https://github.com/eelf) for proxy example [smdbgpproxy](https://github.com/eelf/smdbgpproxy). 138 | 139 | Thanks to [Dmitry Ananyev](https://github.com/altexdim) for help with docs. 140 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | ## PHP xdebug (dbgp) proxy upgrade instructions 2 | 3 | !!!IMPORTANT!!! 4 | 5 | The following upgrading instructions are cumulative. That is, if you want to upgrade from version A to version C and there is version B between A and C, you need to follow the instructions for both A and B. 6 | 7 | ### Upgrade from v0.3.0 8 | - new parameter `$config` was added to `\Mougrim\XdebugProxy\Factory\Factory::createRequestPreparers()`, if you implement `\Mougrim\XdebugProxy\Factory\Factory` interface, then implement this parameter too 9 | - now `\Mougrim\XdebugProxy\Factory\SoftMocksFactory::createConfig()` should return `\Mougrim\XdebugProxy\Config\SoftMocksConfig`, so if you redeclared this method, then return `\Mougrim\XdebugProxy\Config\SoftMocksConfig` or inheritor in it 10 | 11 | ### Upgrade from v0.2.1 12 | - use interface `\Mougrim\XdebugProxy\RequestPreparer\RequestPreparer` instead of `\Mougrim\XdebugProxy\RequestPreparer` 13 | - check your php version, now php 7.1 is minimum php version 14 | - check interface compatible for: 15 | - `\Mougrim\XdebugProxy\Handler\DefaultIdeHandler` 16 | - `\Mougrim\XdebugProxy\RequestPreparer\RequestPreparer` 17 | - `\Mougrim\XdebugProxy\Xml\XmlDocument` 18 | - `\Mougrim\XdebugProxy\Proxy` 19 | - `\Mougrim\XdebugProxy\Runner` 20 | 21 | ### Upgrade from v0.1.0 22 | - New parameter `$config` was added to `\Mougrim\XdebugProxy\Factory\Factory::createIdeHandler()`, if you implement `\Mougrim\XdebugProxy\Factory\Factory` interface, then implement this parameter too. 23 | - New parameter `$config` was added to `\Mougrim\XdebugProxy\Handler\DefaultIdeHandler`'s constructor, if you extends this class' constructor, then implement this parameter. 24 | - Now by default `defaultIde` is `'127.0.0.1:9000'` and `predefinedIdeList` is `'idekey' => '127.0.0.1:9000'`. Pass in `config.php` `ideServer` with `'defaultIde' => ''` and `'predefinedIdeList' => []` if you don't need default ide and predefined ide list. See [README.md](README.md#config) for more details about `config.php`. 25 | 26 | ### Upgrade from v0.0.1 27 | 28 | - class `\Mougrim\XdebugProxy\RequestPreparer` is deprecated and will be removed in next releases, use `\Mougrim\XdebugProxy\RequestPreparer\RequestPreparer` instead of it 29 | - `\Mougrim\XdebugProxy\Handler\CommandToXdebugParser::buildCommand()` method was added, implement this method if you implement it 30 | -------------------------------------------------------------------------------- /bin/xdebug-proxy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 7 | */ 8 | namespace Mougrim\XdebugProxy; 9 | 10 | use function file_exists; 11 | use function fwrite; 12 | use const PHP_EOL; 13 | use const STDERR; 14 | 15 | $composerInstall = ''; 16 | foreach ([__DIR__ . '/../../../autoload.php', __DIR__ . '/../vendor/autoload.php'] as $file) { 17 | if (file_exists($file)) { 18 | $composerInstall = $file; 19 | 20 | break; 21 | } 22 | } 23 | if (!$composerInstall) { 24 | fwrite( 25 | STDERR, 26 | 'You need to set up the project dependencies using Composer:' . PHP_EOL . PHP_EOL . 27 | ' composer install' . PHP_EOL . PHP_EOL . 28 | 'You can learn all about Composer on https://getcomposer.org/.' . PHP_EOL 29 | ); 30 | exit(1); 31 | } 32 | require $composerInstall; 33 | unset($composerInstall); 34 | 35 | (new Runner()) 36 | ->run(); 37 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mougrim/php-xdebug-proxy", 3 | "description": "Xdebug (dbgp) proxy", 4 | "type": "project", 5 | "require": { 6 | "php": ">=8.1", 7 | "amphp/socket": "^2.3" 8 | }, 9 | "require-dev": { 10 | "ext-dom": "*", 11 | "roave/security-advisories": "dev-latest", 12 | "phpro/grumphp": "^2.9", 13 | "friendsofphp/php-cs-fixer": "^3.64.0", 14 | "phpunit/phpunit": "^10.5", 15 | "amphp/log": "^2.0", 16 | "phpstan/phpstan": "^2.0", 17 | "jetbrains/phpstorm-attributes": "^1.2" 18 | }, 19 | "license": "MIT", 20 | "authors": [ 21 | { 22 | "name": "Mougrim", 23 | "email": "rinat@mougrim.ru" 24 | } 25 | ], 26 | "bin": ["bin/xdebug-proxy"], 27 | "autoload": { 28 | "psr-4": { 29 | "Mougrim\\XdebugProxy\\": "src" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "Tests\\Mougrim\\XdebugProxy\\": "tests" 35 | } 36 | }, 37 | "suggest": { 38 | "ext-dom": "Required for parse XML by default", 39 | "ext-pcntl": "Required for graceful stop daemon", 40 | "amphp/log": "Required for write logs by default" 41 | }, 42 | "config": { 43 | "allow-plugins": { 44 | "phpro/grumphp": true 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | 9 | namespace Mougrim\XdebugProxy\config; 10 | 11 | return [ 12 | 'xdebugServer' => [ 13 | 'listen' => '127.0.0.1:9002', 14 | ], 15 | 'ideServer' => [ 16 | 'defaultIde' => '127.0.0.1:9000', 17 | 'predefinedIdeList' => [ 18 | 'idekey' => '127.0.0.1:9000', 19 | ], 20 | ], 21 | 'ideRegistrationServer' => [ 22 | 'listen' => '127.0.0.1:9001', 23 | ], 24 | ]; 25 | -------------------------------------------------------------------------------- /config/factory.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | 9 | namespace Mougrim\XdebugProxy\config; 10 | 11 | use Mougrim\XdebugProxy\Factory\DefaultFactory; 12 | 13 | return new DefaultFactory(); 14 | -------------------------------------------------------------------------------- /config/logger.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | 8 | namespace Mougrim\XdebugProxy\config; 9 | 10 | use Amp\Log\StreamHandler; 11 | use Monolog\Logger; 12 | use Mougrim\XdebugProxy\LoggerFormatter; 13 | use Mougrim\XdebugProxy\RunError; 14 | use Psr\Log\LogLevel; 15 | 16 | use function Amp\ByteStream\getStdout; 17 | use function class_exists; 18 | 19 | if (!class_exists(StreamHandler::class)) { 20 | throw new RunError( 21 | 'You should install "amphp/log" by default or provide your custom config/logger.php via config for use php-xdebug-proxy' 22 | ); 23 | } 24 | 25 | return (new Logger('xdebug-proxy')) 26 | ->pushHandler( 27 | (new StreamHandler(getStdout(), LogLevel::NOTICE)) 28 | ->setFormatter(new LoggerFormatter()) 29 | ); 30 | -------------------------------------------------------------------------------- /grumphp.yml: -------------------------------------------------------------------------------- 1 | grumphp: 2 | hooks_dir: ~ 3 | hooks_preset: local 4 | stop_on_failure: false 5 | ignore_unstaged_changes: false 6 | process_timeout: 300 7 | tasks: 8 | phpcsfixer2: 9 | allow_risky: true 10 | config: .php-cs-fixer.php 11 | phpstan: 12 | autoload_file: ~ 13 | configuration: phpstan.neon 14 | level: null 15 | force_patterns: [] 16 | ignore_patterns: [] 17 | triggered_by: ['php'] 18 | memory_limit: "-1" 19 | use_grumphp_paths: true 20 | extensions: [] 21 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | tmpDir: tmp 3 | level: 10 4 | paths: 5 | - bin 6 | - config 7 | - softMocksConfig 8 | - src 9 | - tests 10 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | tests/Unit 19 | 20 | 21 | 22 | 23 | src 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /softMocksConfig/config.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | 9 | namespace Mougrim\XdebugProxy\softMocksConfig; 10 | 11 | return [ 12 | 'xdebugServer' => [ 13 | 'listen' => '127.0.0.1:9002', 14 | ], 15 | 'ideServer' => [ 16 | 'defaultIde' => '127.0.0.1:9000', 17 | 'predefinedIdeList' => [ 18 | 'idekey' => '127.0.0.1:9000', 19 | ], 20 | ], 21 | 'ideRegistrationServer' => [ 22 | 'listen' => '127.0.0.1:9001', 23 | ], 24 | 'softMocks' => [ 25 | 'initScript' => '', 26 | ], 27 | ]; 28 | -------------------------------------------------------------------------------- /softMocksConfig/factory.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | 9 | namespace Mougrim\XdebugProxy\softMocksConfig; 10 | 11 | use Mougrim\XdebugProxy\Factory\SoftMocksFactory; 12 | 13 | return new SoftMocksFactory(); 14 | -------------------------------------------------------------------------------- /softMocksConfig/logger.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | 8 | namespace Mougrim\XdebugProxy\softMocksConfig; 9 | 10 | use Amp\Log\StreamHandler; 11 | use Monolog\Logger; 12 | use Mougrim\XdebugProxy\LoggerFormatter; 13 | use Mougrim\XdebugProxy\RunError; 14 | use Psr\Log\LogLevel; 15 | 16 | use function Amp\ByteStream\getStdout; 17 | use function class_exists; 18 | 19 | if (!class_exists(StreamHandler::class)) { 20 | throw new RunError( 21 | 'You should install "amphp/log" by default or provide your custom config/logger.php via config for use php-xdebug-proxy' 22 | ); 23 | } 24 | 25 | return (new Logger('xdebug-proxy')) 26 | ->pushHandler( 27 | (new StreamHandler(getStdout(), LogLevel::NOTICE)) 28 | ->setFormatter(new LoggerFormatter()) 29 | ); 30 | -------------------------------------------------------------------------------- /src/Config/Config.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * @phpstan-import-type ServerConfigArray from Server 11 | * @phpstan-import-type ServerDefaultConfigArray from Server 12 | * @phpstan-import-type IdeServerConfigArray from IdeServer 13 | * @phpstan-import-type IdeServerDefaultConfigArray from IdeServer 14 | * 15 | * @phpstan-type XdebugProxyConfigArray array{xdebugServer?: ServerConfigArray, ideRegistrationServer?: ServerConfigArray, ideServer?: IdeServerConfigArray} 16 | */ 17 | class Config 18 | { 19 | /** @var ServerDefaultConfigArray */ 20 | public const DEFAULT_XDEBUG_SERVER_CONFIG = [ 21 | 'listen' => '127.0.0.1:9002', 22 | ]; 23 | 24 | /** @var ServerDefaultConfigArray */ 25 | public const DEFAULT_IDE_REGISTRATION_SERVER_CONFIG = [ 26 | 'listen' => '127.0.0.1:9001', 27 | ]; 28 | 29 | /** @var IdeServerDefaultConfigArray */ 30 | public const DEFAULT_IDE_SERVER_CONFIG = [ 31 | 'defaultIde' => '127.0.0.1:9000', 32 | 'predefinedIdeList' => [ 33 | 'idekey' => '127.0.0.1:9000', 34 | ], 35 | ]; 36 | 37 | protected readonly Server $xdebugServer; 38 | protected readonly Server $ideRegistrationServer; 39 | protected readonly IdeServer $ideServer; 40 | 41 | /** 42 | * @param XdebugProxyConfigArray $config 43 | */ 44 | public function __construct(array $config) 45 | { 46 | $this->xdebugServer = new Server( 47 | $config['xdebugServer'] ?? [], 48 | static::DEFAULT_XDEBUG_SERVER_CONFIG 49 | ); 50 | $this->ideRegistrationServer = new Server( 51 | $config['ideRegistrationServer'] ?? [], 52 | static::DEFAULT_IDE_REGISTRATION_SERVER_CONFIG 53 | ); 54 | $this->ideServer = new IdeServer( 55 | $config['ideServer'] ?? [], 56 | static::DEFAULT_IDE_SERVER_CONFIG 57 | ); 58 | } 59 | 60 | public function getXdebugServer(): Server 61 | { 62 | return $this->xdebugServer; 63 | } 64 | 65 | public function getIdeRegistrationServer(): Server 66 | { 67 | return $this->ideRegistrationServer; 68 | } 69 | 70 | public function getIdeServer(): IdeServer 71 | { 72 | return $this->ideServer; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Config/IdeServer.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * @phpstan-type IdeServerConfigArray array{defaultIde?: string, predefinedIdeList?: array} 11 | * @phpstan-type IdeServerDefaultConfigArray array{defaultIde: string, predefinedIdeList: array} 12 | */ 13 | class IdeServer 14 | { 15 | /** 16 | * @param IdeServerConfigArray $config 17 | * @param IdeServerDefaultConfigArray $defaultConfig 18 | */ 19 | public function __construct( 20 | protected readonly array $config, 21 | protected readonly array $defaultConfig, 22 | ) { 23 | } 24 | 25 | public function getDefaultIde(): string 26 | { 27 | return $this->config['defaultIde'] ?? $this->defaultConfig['defaultIde']; 28 | } 29 | 30 | /** 31 | * @return array 32 | */ 33 | public function getPredefinedIdeList(): array 34 | { 35 | return $this->config['predefinedIdeList'] ?? $this->defaultConfig['predefinedIdeList']; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Config/Server.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * @phpstan-type ServerConfigArray array{listen?: string} 11 | * @phpstan-type ServerDefaultConfigArray array{listen: string} 12 | */ 13 | class Server 14 | { 15 | /** 16 | * @param ServerConfigArray $config 17 | * @param ServerDefaultConfigArray $defaultConfig 18 | */ 19 | public function __construct( 20 | protected readonly array $config, 21 | protected readonly array $defaultConfig, 22 | ) { 23 | } 24 | 25 | public function getListen(): string 26 | { 27 | return $this->config['listen'] ?? $this->defaultConfig['listen']; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Config/SoftMocks.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * @phpstan-type SoftMocksConfigArray array{initScript?: string} 11 | * @phpstan-type SoftMocksDefaultConfigArray array{initScript: string} 12 | */ 13 | class SoftMocks 14 | { 15 | /** 16 | * @param SoftMocksConfigArray $config 17 | * @param SoftMocksDefaultConfigArray $defaultConfig 18 | */ 19 | public function __construct( 20 | protected readonly array $config, 21 | protected readonly array $defaultConfig, 22 | ) { 23 | } 24 | 25 | public function getInitScript(): string 26 | { 27 | return $this->config['initScript'] ?? $this->defaultConfig['initScript']; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Config/SoftMocksConfig.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * @phpstan-import-type ServerConfigArray from Server 11 | * @phpstan-import-type IdeServerConfigArray from IdeServer 12 | * @phpstan-import-type SoftMocksConfigArray from SoftMocks 13 | * @phpstan-import-type SoftMocksDefaultConfigArray from SoftMocks 14 | * 15 | * @phpstan-type XdebugProxySoftMocksConfigArray array{xdebugServer?: ServerConfigArray, ideRegistrationServer?: ServerConfigArray, ideServer?: IdeServerConfigArray, softMocks?: SoftMocksConfigArray} 16 | */ 17 | class SoftMocksConfig extends Config 18 | { 19 | /** @var SoftMocksDefaultConfigArray */ 20 | public const DEFAULT_SOFT_MOCKS_CONFIG = [ 21 | 'initScript' => '', 22 | ]; 23 | 24 | protected readonly SoftMocks $softMocks; 25 | 26 | /** 27 | * @param XdebugProxySoftMocksConfigArray $config 28 | */ 29 | public function __construct(array $config) 30 | { 31 | parent::__construct($config); 32 | $this->softMocks = new SoftMocks( 33 | $config['softMocks'] ?? [], 34 | static::DEFAULT_SOFT_MOCKS_CONFIG 35 | ); 36 | } 37 | 38 | public function getSoftMocks(): SoftMocks 39 | { 40 | return $this->softMocks; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Enum/RegistrationCommand.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | enum RegistrationCommand: string 11 | { 12 | case Init = 'proxyinit'; 13 | case Stop = 'proxystop'; 14 | } 15 | -------------------------------------------------------------------------------- /src/Enum/RegistrationError.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | enum RegistrationError: int 11 | { 12 | case UnknownCommand = 1; 13 | case ArgumentFormat = 2; 14 | case MissingRequiredArguments = 3; 15 | } 16 | -------------------------------------------------------------------------------- /src/Factory/DefaultFactory.php: -------------------------------------------------------------------------------- 1 | 21 | * 22 | * @phpstan-import-type XdebugProxyConfigArray from Config 23 | */ 24 | class DefaultFactory implements Factory 25 | { 26 | /** 27 | * @param array> $config 28 | * @phpstan-param XdebugProxyConfigArray $config 29 | */ 30 | public function createConfig(array $config): Config 31 | { 32 | return new Config($config); 33 | } 34 | 35 | public function createXmlConverter(LoggerInterface $logger): XmlConverter 36 | { 37 | return new DomXmlConverter($logger); 38 | } 39 | 40 | /** 41 | * @param RequestPreparer[] $requestPreparers 42 | */ 43 | public function createIdeHandler( 44 | LoggerInterface $logger, 45 | IdeServerConfig $config, 46 | XmlConverter $xmlConverter, 47 | array $requestPreparers, 48 | ): IdeHandler { 49 | return new DefaultIdeHandler($logger, $config, $xmlConverter, $requestPreparers); 50 | } 51 | 52 | public function createRequestPreparers(LoggerInterface $logger, Config $config): array 53 | { 54 | return []; 55 | } 56 | 57 | public function createXdebugHandler( 58 | LoggerInterface $logger, 59 | XmlConverter $xmlConverter, 60 | IdeHandler $ideHandler, 61 | ): XdebugHandler { 62 | return new DefaultXdebugHandler($logger, $xmlConverter, $ideHandler); 63 | } 64 | 65 | public function createProxy( 66 | LoggerInterface $logger, 67 | Config $config, 68 | XmlConverter $xmlConverter, 69 | IdeHandler $ideHandler, 70 | XdebugHandler $xdebugHandler, 71 | ): Proxy { 72 | return new Proxy($logger, $config, $xmlConverter, $ideHandler, $xdebugHandler); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Factory/Factory.php: -------------------------------------------------------------------------------- 1 | 20 | * 21 | * @phpstan-import-type XdebugProxyConfigArray from Config 22 | */ 23 | interface Factory 24 | { 25 | /** 26 | * @param array> $config 27 | * @phpstan-param XdebugProxyConfigArray $config 28 | */ 29 | public function createConfig(array $config): Config; 30 | 31 | public function createXmlConverter(LoggerInterface $logger): XmlConverter; 32 | 33 | /** 34 | * @param RequestPreparer[] $requestPreparers 35 | */ 36 | public function createIdeHandler( 37 | LoggerInterface $logger, 38 | IdeServerConfig $config, 39 | XmlConverter $xmlConverter, 40 | array $requestPreparers, 41 | ): IdeHandler; 42 | 43 | /** 44 | * Request preparers will be called: 45 | * - on request to ide from first to last; 46 | * - on request to xdebug from last to first. 47 | * 48 | * @return RequestPreparer[] 49 | * 50 | * @throws RequestPreparerException 51 | * @throws RequestPreparerError 52 | */ 53 | public function createRequestPreparers(LoggerInterface $logger, Config $config): array; 54 | 55 | public function createXdebugHandler( 56 | LoggerInterface $logger, 57 | XmlConverter $xmlConverter, 58 | IdeHandler $ideHandler, 59 | ): XdebugHandler; 60 | 61 | public function createProxy( 62 | LoggerInterface $logger, 63 | Config $config, 64 | XmlConverter $xmlConverter, 65 | IdeHandler $ideHandler, 66 | XdebugHandler $xdebugHandler, 67 | ): Proxy; 68 | } 69 | -------------------------------------------------------------------------------- /src/Factory/SoftMocksFactory.php: -------------------------------------------------------------------------------- 1 | 17 | * 18 | * @phpstan-import-type XdebugProxySoftMocksConfigArray from SoftMocksConfig 19 | */ 20 | class SoftMocksFactory extends DefaultFactory 21 | { 22 | /** 23 | * @noinspection PhpMissingParentCallCommonInspection 24 | * 25 | * @param XdebugProxySoftMocksConfigArray $config 26 | * @return SoftMocksConfig 27 | */ 28 | public function createConfig(array $config): Config 29 | { 30 | return new SoftMocksConfig($config); 31 | } 32 | 33 | /** 34 | * @param SoftMocksConfig $config 35 | * @return RequestPreparer[] 36 | * 37 | * @throws RequestPreparerException 38 | * @throws RequestPreparerError 39 | */ 40 | public function createRequestPreparers(LoggerInterface $logger, Config $config): array 41 | { 42 | $requestPreparers = parent::createRequestPreparers($logger, $config); 43 | $requestPreparers[] = $this->createSoftMocksRequestPreparer($logger, $config); 44 | 45 | return $requestPreparers; 46 | } 47 | 48 | /** 49 | * @throws RequestPreparerError 50 | */ 51 | public function createSoftMocksRequestPreparer( 52 | LoggerInterface $logger, 53 | SoftMocksConfig $config, 54 | ): SoftMocksRequestPreparer { 55 | return new SoftMocksRequestPreparer($logger, $config->getSoftMocks()->getInitScript()); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Handler/CommandToXdebugParser.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | interface CommandToXdebugParser 11 | { 12 | /** 13 | * @return array{0: string, 1: array} [$command, $arguments] 14 | */ 15 | public function parseCommand(string $request): array; 16 | 17 | /** 18 | * @param array $arguments 19 | */ 20 | public function buildCommand(string $command, array $arguments): string; 21 | } 22 | -------------------------------------------------------------------------------- /src/Handler/DefaultIdeHandler.php: -------------------------------------------------------------------------------- 1 | 40 | */ 41 | class DefaultIdeHandler implements IdeHandler, CommandToXdebugParser 42 | { 43 | protected readonly string $defaultIde; 44 | /** @var array */ 45 | protected array $ideList; 46 | /** 47 | * @var SplObjectStorage 48 | */ 49 | protected readonly SplObjectStorage $ideSockets; 50 | protected int $maxIdeSockets = 100; 51 | 52 | /** 53 | * @param RequestPreparer[] $requestPreparers 54 | */ 55 | public function __construct( 56 | protected readonly LoggerInterface $logger, 57 | protected readonly IdeServerConfig $config, 58 | protected readonly XmlConverter $xmlConverter, 59 | protected readonly array $requestPreparers, 60 | ) { 61 | $this->ideSockets = new SplObjectStorage(); 62 | $this->defaultIde = $config->getDefaultIde(); 63 | if ($this->defaultIde) { 64 | $this->logger->notice("Use default ide: {$this->defaultIde}"); 65 | } 66 | $this->ideList = $config->getPredefinedIdeList(); 67 | if ($this->ideList) { 68 | $this->logger->notice('Use predefined ides', ['predefinedIdeList' => $this->ideList]); 69 | } 70 | } 71 | 72 | public function getMaxIdeSockets(): int 73 | { 74 | return $this->maxIdeSockets; 75 | } 76 | 77 | public function setMaxIdeSockets(int $maxIdeSockets): static 78 | { 79 | $this->maxIdeSockets = $maxIdeSockets; 80 | 81 | return $this; 82 | } 83 | 84 | public function getIdeList(): array 85 | { 86 | return $this->ideList; 87 | } 88 | 89 | public function handle(ResourceSocket $socket): void 90 | { 91 | [$ip, $port] = explode(':', $socket->getRemoteAddress()->toString()); 92 | $baseContext = [ 93 | 'ide' => "{$ip}:{$port}", 94 | ]; 95 | $this->logger->notice('[IdeRegistration] Accepted connection.', $baseContext); 96 | 97 | $request = ''; 98 | while (($data = $socket->read()) !== null) { 99 | $request .= $data; 100 | if (str_contains($request, "\0")) { 101 | break; 102 | } 103 | } 104 | $requests = explode("\0", $request); 105 | if ($request && $request[strlen($request) - 1] !== "\0") { 106 | $this->logger->warning( 107 | "[IdeRegistration] Part of request isn't full, skip it", 108 | $baseContext + ['request' => $request] 109 | ); 110 | $requests = array_slice($requests, 0, -1); 111 | } 112 | $responses = []; 113 | foreach ($requests as $request) { 114 | if (!$request) { 115 | continue; 116 | } 117 | $context = $baseContext; 118 | $context['request'] = $request; 119 | if (!str_contains($request, ' ')) { 120 | $this->logger->error('[IdeRegistration] Invalid request from IDE.', $context); 121 | continue; 122 | } 123 | $this->logger->notice('[IdeRegistration] Process request from IDE.', $context); 124 | 125 | [$command, $arguments] = $this->parseCommand($request); 126 | $context['command'] = $command; 127 | 128 | try { 129 | if (isset(static::REGISTRATION_ARGUMENTS[$command])) { 130 | $unsupportedArguments = array_diff( 131 | array_keys($arguments), 132 | static::REGISTRATION_ARGUMENTS[$command]['supportedArguments'] 133 | ); 134 | if ($unsupportedArguments) { 135 | $this->logger->warning( 136 | '[IdeRegistration] Skip unsupported arguments.', 137 | $context + ['unsupportedArguments' => $unsupportedArguments] 138 | ); 139 | } 140 | $missingRequiredArguments = array_diff( 141 | static::REGISTRATION_ARGUMENTS[$command]['requiredArguments'], 142 | array_keys($arguments) 143 | ); 144 | if ($missingRequiredArguments) { 145 | $this->logger->error( 146 | '[IdeRegistration] Missing required arguments.', 147 | $context + ['missingRequiredArguments' => $missingRequiredArguments] 148 | ); 149 | throw new IdeRegistrationException( 150 | RegistrationError::MissingRequiredArguments, 151 | 'Next required arguments are missing: ' 152 | . implode(', ', $missingRequiredArguments), 153 | $command 154 | ); 155 | } 156 | } 157 | 158 | switch ($command) { 159 | case 'proxyinit': 160 | $context['key'] = $arguments['-k']; 161 | if (!preg_match('/^[1-9]\d*$/', $arguments['-p'])) { 162 | $this->logger->error( 163 | '[IdeRegistration] Port should be a number.', 164 | $context + ['port' => $arguments['-p']] 165 | ); 166 | throw new IdeRegistrationException( 167 | RegistrationError::ArgumentFormat, 168 | 'Port should be a number', 169 | $command 170 | ); 171 | } 172 | $newIde = "{$ip}:{$arguments['-p']}"; 173 | $context['ide'] = $newIde; 174 | if (isset($this->ideList[$arguments['-k']])) { 175 | $this->logger->notice( 176 | "[IdeRegistration] Change ide from '{$this->ideList[$arguments['-k']]}' to '{$newIde}'", 177 | $context 178 | ); 179 | } else { 180 | $this->logger->notice( 181 | "[IdeRegistration] Add new ide '{$newIde}'", 182 | $context 183 | ); 184 | } 185 | $this->ideList[$arguments['-k']] = $newIde; 186 | $xmlContainer = (new XmlContainer('proxyinit')) 187 | ->addAttribute('success', '1') 188 | ->addAttribute('idekey', $arguments['-k']) 189 | ->addAttribute('address', $ip) 190 | ->addAttribute('port', $arguments['-p']); 191 | break; 192 | case 'proxystop': 193 | if (isset($this->ideList[$arguments['-k']])) { 194 | $this->logger->notice( 195 | "[IdeRegistration] Remove ide key '{$arguments['-k']}' in '{$this->ideList[$arguments['-k']]}'", 196 | $context 197 | ); 198 | unset($this->ideList[$arguments['-k']]); 199 | } else { 200 | $this->logger->notice( 201 | "[IdeRegistration] Ide key '{$arguments['-k']}' isn't used", 202 | $context 203 | ); 204 | } 205 | $xmlContainer = (new XmlContainer('proxystop')) 206 | ->addAttribute('success', '1') 207 | ->addAttribute('idekey', $arguments['-k']); 208 | break; 209 | default: 210 | $this->logger->error('[IdeRegistration] Unknown command from IDE.', $context); 211 | throw new IdeRegistrationException( 212 | RegistrationError::UnknownCommand, 213 | "Unknown command '{$command}'" 214 | ); 215 | } 216 | } catch (IdeRegistrationException $exception) { 217 | $xmlContainerMessage = (new XmlContainer('message')) 218 | ->setContent($exception->getMessage()); 219 | $xmlContainerError = (new XmlContainer('error')) 220 | ->addAttribute('id', (string) $exception->getError()->value) 221 | ->addChild($xmlContainerMessage); 222 | $xmlContainer = (new XmlContainer($exception->getCommand())) 223 | ->addAttribute('success', '0') 224 | ->addChild($xmlContainerError); 225 | } 226 | $xmlDocument = new XmlDocument('1.0', 'UTF-8', $xmlContainer); 227 | try { 228 | $responses[] = $this->xmlConverter->generate($xmlDocument); 229 | } catch (XmlException $exception) { 230 | $this->logger->notice("[IdeRegistration] Can't generate response: {$exception}", $context); 231 | try { 232 | $socket->end(); 233 | } /** @noinspection BadExceptionsProcessingInspection */ catch (ClosedException|StreamException) { 234 | // we can't do anything else after try to close connection 235 | } 236 | 237 | return; 238 | } 239 | } 240 | $response = ''; 241 | if ($responses) { 242 | $response = implode("\0", $responses); 243 | } 244 | $this->logger->notice( 245 | '[IdeRegistration] Send response.', 246 | $baseContext + ['response' => $response] 247 | ); 248 | try { 249 | $socket->write($response); 250 | $socket->end(); 251 | } catch (ClosedException|StreamException $exception) { 252 | $this->logger->error( 253 | "[IdeRegistration] Can't write response to ide: {$exception}", 254 | $baseContext + ['response' => $response] 255 | ); 256 | } 257 | } 258 | 259 | /** 260 | * @throws FromXdebugProcessError 261 | * @throws FromXdebugProcessException 262 | */ 263 | public function processRequest(XmlDocument $xmlRequest, string $rawRequest, ResourceSocket $xdebugSocket): void 264 | { 265 | $context = [ 266 | 'xdebug' => $xdebugSocket->getRemoteAddress()->toString(), 267 | 'request' => $rawRequest, 268 | ]; 269 | if (!$this->ideSockets->contains($xdebugSocket)) { 270 | $this->processInit($xmlRequest, $rawRequest, $xdebugSocket); 271 | } 272 | $ideSocket = $this->ideSockets->offsetGet($xdebugSocket); 273 | $context['ide'] = $ideSocket->getRemoteAddress()->toString(); 274 | try { 275 | $this->prepareRequestToIde($xmlRequest, $rawRequest, $context); 276 | } catch (RequestPreparerError $error) { 277 | throw new FromXdebugProcessError("Can't prepare request to ide", $context, $error); 278 | } 279 | 280 | try { 281 | $request = $this->xmlConverter->generate($xmlRequest); 282 | } catch (XmlException $exception) { 283 | throw new FromXdebugProcessError("Can't generate response", $context, $exception); 284 | } 285 | try { 286 | $ideSocket->write(strlen($request) . "\0{$request}\0"); 287 | } catch (ClosedException|StreamException $exception) { 288 | throw new FromXdebugProcessError( 289 | "Can't send request to ide", 290 | $context + ['generatedRequest' => $request], 291 | $exception 292 | ); 293 | } 294 | 295 | $this->logger->debug('[Xdebug][Ide] Request was sent to ide, waiting response.', $context); 296 | } 297 | 298 | /** 299 | * @param array $context 300 | * 301 | * @throws RequestPreparerError 302 | */ 303 | protected function prepareRequestToIde(XmlDocument $xmlRequest, string $rawRequest, array $context): void 304 | { 305 | foreach ($this->requestPreparers as $requestPreparer) { 306 | try { 307 | $requestPreparer->prepareRequestToIde($xmlRequest, $rawRequest); 308 | } catch (RequestPreparerException $exception) { 309 | $this->logger->error( 310 | "Can't prepare request to ide: {$exception}", 311 | $context + [ 312 | 'preparer' => get_class($requestPreparer), 313 | ] 314 | ); 315 | } 316 | } 317 | } 318 | 319 | public function close(ResourceSocket $xdebugSocket): void 320 | { 321 | if (!$this->ideSockets->contains($xdebugSocket)) { 322 | return; 323 | } 324 | $ideSocket = $this->ideSockets->offsetGet($xdebugSocket); 325 | 326 | try { 327 | $ideSocket->end(); 328 | } /** @noinspection BadExceptionsProcessingInspection */ catch (ClosedException) { 329 | // already closed 330 | } catch (StreamException $exception) { 331 | $this->logger->error( 332 | "Can't close ide socket: {$exception}", 333 | ['exception' => $exception], 334 | ); 335 | } 336 | $this->ideSockets->detach($xdebugSocket); 337 | } 338 | 339 | /** 340 | * @throws FromXdebugProcessError 341 | * @throws FromXdebugProcessException 342 | */ 343 | protected function processInit(XmlDocument $xmlRequest, string $rawRequest, ResourceSocket $xdebugSocket): void 344 | { 345 | $context = [ 346 | 'xdebug' => $xdebugSocket->getRemoteAddress()->toString(), 347 | 'request' => $rawRequest, 348 | ]; 349 | $xmlContainer = $xmlRequest->getRoot(); 350 | if (!$xmlContainer) { 351 | throw new FromXdebugProcessError("Can't get document root", $context); 352 | } 353 | 354 | if ($xmlContainer->getName() !== 'init') { 355 | throw new FromXdebugProcessError("First request's root should be init", $context); 356 | } 357 | 358 | $ideKey = $xmlContainer->getAttributes()['idekey'] ?? null; 359 | if (!$ideKey) { 360 | throw new FromXdebugProcessError('Ide key is empty', $context); 361 | } 362 | $context['ideKey'] = $ideKey; 363 | $ide = $this->ideList[$ideKey] ?? $this->defaultIde; 364 | if (!$ide) { 365 | throw new FromXdebugProcessException('No any ide', $context); 366 | } 367 | $context['ide'] = $ide; 368 | 369 | if ($this->ideSockets->count() >= $this->maxIdeSockets) { 370 | throw new FromXdebugProcessException('Max connections exceeded', $context); 371 | } 372 | 373 | $this->logger->notice('[Xdebug][Ide][Init] Try to init connect to ide.', $context); 374 | 375 | try { 376 | $ideSocket = connect("tcp://{$ide}"); 377 | } catch (ConnectException|CancelledException $exception) { 378 | throw new FromXdebugProcessError("Can't connect to ide", $context, $exception); 379 | } 380 | $this->ideSockets->attach($xdebugSocket, $ideSocket); 381 | 382 | $this->logger->notice('[Xdebug][Ide][Init] Successful connected to ide.', $context); 383 | 384 | async(fn () => $this->handleIde($ideKey, $xdebugSocket, $ideSocket)); 385 | } 386 | 387 | protected function handleIde(string $ideKey, ResourceSocket $xdebugSocket, Socket $ideSocket): void 388 | { 389 | $context = [ 390 | 'ide' => $ideSocket->getRemoteAddress()->toString(), 391 | 'key' => $ideKey, 392 | 'xdebug' => $xdebugSocket->getRemoteAddress()->toString(), 393 | ]; 394 | $buffer = ''; 395 | try { 396 | while (($chunk = $ideSocket->read()) !== null) { 397 | $buffer .= $chunk; 398 | while (str_contains($buffer, "\0")) { 399 | [$request, $buffer] = explode("\0", $buffer, 2); 400 | $this->logger->info( 401 | '[Xdebug][Ide] Process ide request', 402 | $context + ['request' => $request] 403 | ); 404 | $request = $this->prepareRequestToXdebug($request, $context); 405 | $this->logger->debug( 406 | '[Xdebug][Ide] Send prepared request to xdebug', 407 | $context + ['request' => $request] 408 | ); 409 | $xdebugSocket->write($request . "\0"); 410 | } 411 | } 412 | } /** @noinspection BadExceptionsProcessingInspection */ catch (ClosedException $exception) { 413 | // skip exception, close other connections below 414 | } catch (StreamException|RequestPreparerError $error) { 415 | $this->logger->critical( 416 | "Can't prepare request", 417 | $context + [ 418 | 'exception' => $error, 419 | ], 420 | ); 421 | // close other connections below 422 | } 423 | 424 | if ($buffer) { 425 | $this->logger->error( 426 | "[Xdebug][Ide] Buffer isn't empty after end handle from ide", 427 | $context + ['buffer' => $buffer] 428 | ); 429 | } 430 | 431 | $this->logger->notice('[Xdebug][Ide] End handle from ide, close connections.', $context); 432 | $this->close($xdebugSocket); 433 | try { 434 | $xdebugSocket->end(); 435 | } /** @noinspection BadExceptionsProcessingInspection */ catch (ClosedException) { 436 | // already closed 437 | } catch (StreamException $exception) { 438 | $this->logger->error( 439 | "Can't close xdebug socket", 440 | $context + ['exception' => $exception] 441 | ); 442 | } 443 | } 444 | 445 | /** 446 | * @param array $context 447 | * @return string prepared request 448 | * 449 | * @throws RequestPreparerError 450 | */ 451 | protected function prepareRequestToXdebug(string $request, array $context): string 452 | { 453 | foreach (array_reverse($this->requestPreparers) as $requestPreparer) { 454 | try { 455 | $request = $requestPreparer->prepareRequestToXdebug($request, $this); 456 | } catch (RequestPreparerException $exception) { 457 | $this->logger->error( 458 | "Can't prepare request to xdebug: {$exception}", 459 | $context + [ 460 | 'preparer' => get_class($requestPreparer), 461 | 'request' => $request, 462 | ] 463 | ); 464 | } 465 | } 466 | 467 | return $request; 468 | } 469 | 470 | /** 471 | * @return array{string, array} 472 | */ 473 | public function parseCommand(string $request): array 474 | { 475 | [$command, $arguments] = explode(' ', $request, 2); 476 | $arguments = $this->parseArguments($arguments); 477 | 478 | return [$command, $arguments]; 479 | } 480 | 481 | /** 482 | * @param array $arguments 483 | */ 484 | public function buildCommand(string $command, array $arguments): string 485 | { 486 | $argumentStrings = []; 487 | foreach ($arguments as $argument => $value) { 488 | $argumentStrings[] = "{$argument} {$value}"; 489 | } 490 | 491 | return $command . ' ' . implode(' ', $argumentStrings); 492 | } 493 | 494 | /** 495 | * @return array 496 | */ 497 | protected function parseArguments(string $arguments): array 498 | { 499 | $context = [ 500 | 'arguments' => $arguments, 501 | ]; 502 | $parts = explode(' ', $arguments); 503 | $partsQty = count($parts); 504 | /** @var array $result */ 505 | $result = []; 506 | /** @noinspection ForeachInvariantsInspection */ 507 | for ($i = 0; $i < $partsQty; $i++) { 508 | $part = $parts[$i]; 509 | if (!$part) { 510 | continue; 511 | } 512 | if ($part[0] !== '-') { 513 | $this->logger->error("Can't parse argument {$part}", $context); 514 | continue; 515 | } 516 | if (!isset($parts[$i + 1])) { 517 | $this->logger->error("Can't get value argument {$part}", $context); 518 | break; 519 | } 520 | 521 | $i++; 522 | $result[$part] = $parts[$i]; 523 | } 524 | 525 | return $result; 526 | } 527 | } 528 | -------------------------------------------------------------------------------- /src/Handler/DefaultXdebugHandler.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class DefaultXdebugHandler implements XdebugHandler 23 | { 24 | /** 25 | * @var SplObjectStorage 26 | */ 27 | protected SplObjectStorage $requestBuffers; 28 | 29 | public function __construct( 30 | protected LoggerInterface $logger, 31 | protected XmlConverter $xmlConverter, 32 | protected IdeHandler $ideHandler, 33 | ) { 34 | $this->requestBuffers = new SplObjectStorage(); 35 | } 36 | 37 | public function handle(ResourceSocket $socket): void 38 | { 39 | $baseContext = [ 40 | 'xdebug' => $socket->getRemoteAddress()->toString(), 41 | ]; 42 | $this->logger->notice('[Xdebug] Accepted connection', $baseContext); 43 | 44 | if (!$this->requestBuffers->contains($socket)) { 45 | $this->requestBuffers->attach($socket, ''); 46 | } 47 | while (($data = $socket->read()) !== null) { 48 | $buffer = $this->requestBuffers->offsetGet($socket); 49 | $buffer .= $data; 50 | while (substr_count($buffer, "\0") >= 2) { 51 | $exception = null; 52 | try { 53 | [$length, $request, $buffer] = explode("\0", $buffer, 3); 54 | $context = $baseContext + ['request' => $request, 'requestLength' => $length]; 55 | $this->logger->info('[Xdebug] Process request', $context); 56 | 57 | $requestLength = mb_strlen($request, '8bit'); 58 | if ((string) $requestLength !== $length) { 59 | throw new FromXdebugProcessError( 60 | 'Wrong request length', 61 | $context + ['actualRequestLength' => $length] 62 | ); 63 | } 64 | 65 | try { 66 | $xmlRequest = $this->xmlConverter->parse($request); 67 | } catch (XmlException $exception) { 68 | throw new FromXdebugProcessError("Can't parse request", $context, $exception); 69 | } 70 | $this->ideHandler->processRequest($xmlRequest, $request, $socket); 71 | } catch (FromXdebugProcessError $exception) { 72 | $message = "[Xdebug] {$exception->getMessage()}"; 73 | if ($exception->getPrevious()) { 74 | $message .= ": {$exception->getPrevious()}"; 75 | } 76 | $this->logger->error($message, $exception->getContext()); 77 | } catch (FromXdebugProcessException $exception) { 78 | $message = "[Xdebug] {$exception->getMessage()}"; 79 | if ($exception->getPrevious()) { 80 | $message .= ": {$exception->getPrevious()}"; 81 | } 82 | $this->logger->notice($message, $exception->getContext()); 83 | } 84 | if ($exception) { 85 | $this->ideHandler->close($socket); 86 | $this->requestBuffers->detach($socket); 87 | try { 88 | $socket->end(); 89 | } /** @noinspection BadExceptionsProcessingInspection */ catch (ClosedException) { 90 | // already closed 91 | } catch (StreamException $exception) { 92 | $this->logger->error("Can't close socket", $context + ['exception' => $exception]); 93 | } 94 | 95 | return; 96 | } 97 | } 98 | $this->requestBuffers->attach($socket, $buffer); 99 | } 100 | $buffer = $this->requestBuffers->offsetGet($socket); 101 | if ($buffer) { 102 | $this->logger->warning("[Xdebug] Part of data wasn't parsed", $baseContext + ['buffer']); 103 | } 104 | $this->requestBuffers->detach($socket); 105 | $this->ideHandler->close($socket); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Handler/Exception.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class Exception extends BaseException 14 | { 15 | /** 16 | * @param array $context 17 | */ 18 | public function __construct( 19 | string $message, 20 | protected array $context = [], 21 | ?Throwable $previous = null, 22 | ) { 23 | parent::__construct($message, 0, $previous); 24 | } 25 | 26 | /** 27 | * @return array 28 | */ 29 | public function getContext(): array 30 | { 31 | return $this->context; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Handler/FromXdebugProcessError.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class FromXdebugProcessError extends Exception 11 | { 12 | } 13 | -------------------------------------------------------------------------------- /src/Handler/FromXdebugProcessException.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class FromXdebugProcessException extends Exception 11 | { 12 | } 13 | -------------------------------------------------------------------------------- /src/Handler/Handler.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | interface Handler 13 | { 14 | public function handle(ResourceSocket $socket): void; 15 | } 16 | -------------------------------------------------------------------------------- /src/Handler/IdeHandler.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | interface IdeHandler extends Handler 15 | { 16 | /** @var array, requiredArguments: array}> */ 17 | public const REGISTRATION_ARGUMENTS = [ 18 | /** @uses RegistrationCommand::Init */ 19 | 'proxyinit' => [ 20 | 'supportedArguments' => ['-p', '-k'], 21 | 'requiredArguments' => ['-p', '-k'], 22 | ], 23 | /** @uses RegistrationCommand::Stop */ 24 | 'proxystop' => [ 25 | 'supportedArguments' => ['-k'], 26 | 'requiredArguments' => ['-k'], 27 | ], 28 | ]; 29 | 30 | /** 31 | * @return array 32 | */ 33 | public function getIdeList(): array; 34 | 35 | /** 36 | * @throws FromXdebugProcessError 37 | * @throws FromXdebugProcessException 38 | */ 39 | public function processRequest(XmlDocument $xmlRequest, string $rawRequest, ResourceSocket $xdebugSocket): void; 40 | 41 | public function close(ResourceSocket $xdebugSocket): void; 42 | } 43 | -------------------------------------------------------------------------------- /src/Handler/IdeRegistrationException.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class IdeRegistrationException extends Exception 14 | { 15 | public function __construct( 16 | protected RegistrationError $error, 17 | string $message, 18 | protected string $command = 'proxyerror', 19 | ) { 20 | parent::__construct($message, $error->value); 21 | } 22 | 23 | public function getError(): RegistrationError 24 | { 25 | return $this->error; 26 | } 27 | 28 | public function getCommand(): string 29 | { 30 | return $this->command; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Handler/XdebugHandler.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | interface XdebugHandler extends Handler 11 | { 12 | } 13 | -------------------------------------------------------------------------------- /src/LoggerFormatter.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class LoggerFormatter extends LineFormatter 16 | { 17 | /** @noinspection PhpMissingParentCallCommonInspection */ 18 | protected function convertToString($data): string 19 | { 20 | if (is_scalar($data)) { 21 | return (string) $data; 22 | } 23 | 24 | return var_export($data, true); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Proxy.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | class Proxy 28 | { 29 | /** @var ResourceServerSocket[] */ 30 | protected array $servers = []; 31 | 32 | public function __construct( 33 | protected readonly LoggerInterface $logger, 34 | protected readonly Config $config, 35 | protected readonly XmlConverter $xmlConverter, 36 | protected readonly IdeHandler $ideHandler, 37 | protected readonly XdebugHandler $xdebugHandler, 38 | ) { 39 | } 40 | 41 | /** 42 | * @throws UnsupportedFeatureException 43 | */ 44 | public function run(): void 45 | { 46 | if (extension_loaded('pcntl')) { 47 | $terminateClosure = fn (string $callbackId, int $signal) => $this->terminate(); 48 | EventLoop::onSignal(SIGTERM, $terminateClosure); 49 | EventLoop::onSignal(SIGINT, $terminateClosure); 50 | } 51 | 52 | async(fn () => $this->runIdeRegistration()); 53 | async(fn () => $this->runXdebug()); 54 | EventLoop::run(); 55 | } 56 | 57 | /** 58 | * @throws SocketException 59 | */ 60 | public function runXdebug(): void 61 | { 62 | $server = listen($this->config->getXdebugServer()->getListen()); 63 | $this->servers[] = $server; 64 | $this->logger->notice("[Proxy][Xdebug] Listening for new connections on '{$server->getAddress()->toString()}'..."); 65 | while ($client = $server->accept()) { 66 | async(fn () => $this->xdebugHandler->handle($client)); 67 | } 68 | } 69 | 70 | /** 71 | * @throws SocketException 72 | */ 73 | public function runIdeRegistration(): void 74 | { 75 | $listen = $this->config->getIdeRegistrationServer()->getListen(); 76 | if (!$listen) { 77 | $this->logger->notice('[Proxy][IdeRegistration] IDE registration is disabled by config, skip it.'); 78 | 79 | return; 80 | } 81 | $server = listen($listen); 82 | $this->servers[] = $server; 83 | $this->logger->notice( 84 | "[Proxy][IdeRegistration] Listening for new connections on '{$server->getAddress()->toString()}'..." 85 | ); 86 | while ($socket = $server->accept()) { 87 | async(fn () => $this->ideHandler->handle($socket)); 88 | } 89 | } 90 | 91 | public function terminate(): void 92 | { 93 | foreach ($this->servers as $server) { 94 | $server->close(); 95 | } 96 | $this->logger->notice('[Proxy][Terminating] Terminating proxy server.'); 97 | EventLoop::getDriver()->stop(); 98 | $this->servers = []; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/RequestPreparer/Error.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class Error extends RuntimeException 13 | { 14 | } 15 | -------------------------------------------------------------------------------- /src/RequestPreparer/Exception.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class Exception extends BaseException 13 | { 14 | } 15 | -------------------------------------------------------------------------------- /src/RequestPreparer/RequestPreparer.php: -------------------------------------------------------------------------------- 1 | 12 | * You can use request preparer for example for changing path to files (in break points and execution files). 13 | */ 14 | interface RequestPreparer 15 | { 16 | /** 17 | * In this method you can change $xmlRequest, which will be sent to ide. 18 | * 19 | * @param XmlDocument $xmlRequest request from xdebug to ide 20 | * @param string $rawRequest just for logging purposes 21 | * 22 | * @throws Exception 23 | * @throws Error 24 | */ 25 | public function prepareRequestToIde(XmlDocument $xmlRequest, string $rawRequest): void; 26 | 27 | /** 28 | * This method should return request based on $request, which will be sent to xdebug. 29 | * Use $commandToXdebugParser to parse the command in request and to rebuild the command. 30 | * 31 | * @param string $request command from ide to xdebug 32 | * 33 | * @throws Exception 34 | * @throws Error 35 | */ 36 | public function prepareRequestToXdebug(string $request, CommandToXdebugParser $commandToXdebugParser): string; 37 | } 38 | -------------------------------------------------------------------------------- /src/RequestPreparer/SoftMocksRequestPreparer.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | class SoftMocksRequestPreparer implements RequestPreparer 25 | { 26 | /** 27 | * @throws Error 28 | */ 29 | public function __construct( 30 | protected readonly LoggerInterface $logger, 31 | string $initScript = '', 32 | ) { 33 | if (!$initScript) { 34 | $possibleInitScriptPaths = [ 35 | __DIR__ . '/../../vendor/badoo/soft-mocks/src/init_with_composer.php', 36 | __DIR__ . '/../../../../badoo/soft-mocks/src/init_with_composer.php', 37 | ]; 38 | foreach ($possibleInitScriptPaths as $possiblInitScriptPath) { 39 | if (file_exists($possiblInitScriptPath)) { 40 | $initScript = $possiblInitScriptPath; 41 | 42 | break; 43 | } 44 | } 45 | } 46 | 47 | if (!$initScript) { 48 | throw new Error("Can't find soft-mocks init script"); 49 | } 50 | require $initScript; 51 | } 52 | 53 | public function prepareRequestToIde(XmlDocument $xmlRequest, string $rawRequest): void 54 | { 55 | $context = [ 56 | 'request' => $rawRequest, 57 | ]; 58 | $root = $xmlRequest->getRoot(); 59 | if (!$root) { 60 | return; 61 | } 62 | foreach ($root->getChildren() as $child) { 63 | if (!in_array($child->getName(), ['stack', 'xdebug:message'], true)) { 64 | continue; 65 | } 66 | $attributes = $child->getAttributes(); 67 | if (isset($attributes['filename'])) { 68 | $filename = $this->getOriginalFilePath($attributes['filename'], $context); 69 | if ($attributes['filename'] !== $filename) { 70 | $this->logger->info("Change '{$attributes['filename']}' to '{$filename}'", $context); 71 | $child->addAttribute('filename', $filename); 72 | } 73 | } 74 | } 75 | } 76 | 77 | /** 78 | * @param array $context 79 | */ 80 | protected function getOriginalFilePath(string $file, array $context): string 81 | { 82 | // workaround some symbols like '+' are encoded like %2B 83 | $file = rawurldecode($file); 84 | $parts = parse_url($file); 85 | if ($parts === false) { 86 | $this->logger->warning("Can't parse file '{$file}'", $context); 87 | 88 | return $file; 89 | } 90 | /** @noinspection OffsetOperationsInspection */ 91 | if (($parts['scheme'] ?? '') !== 'file') { 92 | $this->logger->warning("Scheme isn't file '{$file}'", $context); 93 | 94 | return $file; 95 | } 96 | 97 | try { 98 | /** @noinspection PhpUndefinedClassInspection */ 99 | /** @noinspection OffsetOperationsInspection */ 100 | /** @phpstan-ignore binaryOp.invalid, class.notFound */ 101 | return 'file://' . SoftMocks::getOriginalFilePath($parts['path'] ?? ''); 102 | } catch (Throwable $throwable) { 103 | $this->logger->warning("Can't get original file path: {$throwable}", $context); 104 | 105 | return $file; 106 | } 107 | } 108 | 109 | public function prepareRequestToXdebug(string $request, CommandToXdebugParser $commandToXdebugParser): string 110 | { 111 | [$command, $arguments] = $commandToXdebugParser->parseCommand($request); 112 | $context = [ 113 | 'request' => $request, 114 | 'arguments' => $arguments, 115 | ]; 116 | if ($command === 'breakpoint_set') { 117 | if (isset($arguments['-f'])) { 118 | $file = $this->getRewrittenFilePath($arguments['-f'], $context); 119 | if ($file) { 120 | $this->logger->info("Change '{$arguments['-f']}' to '{$file}'", $context); 121 | $arguments['-f'] = $file; 122 | $request = $commandToXdebugParser->buildCommand($command, $arguments); 123 | } 124 | } else { 125 | $this->logger->error("Command {$command} is without argument '-f'", $context); 126 | } 127 | } 128 | 129 | return $request; 130 | } 131 | 132 | /** 133 | * @param array $context 134 | */ 135 | protected function getRewrittenFilePath(string $file, array $context): string 136 | { 137 | $originalFile = $file; 138 | $parts = parse_url($file); 139 | if ($parts === false) { 140 | $this->logger->warning("Can't parse file '{$file}'", $context); 141 | 142 | return ''; 143 | } 144 | /** @noinspection OffsetOperationsInspection */ 145 | if (($parts['scheme'] ?? '') !== 'file') { 146 | $this->logger->warning("Scheme isn't file '{$file}'", $context); 147 | 148 | return ''; 149 | } 150 | try { 151 | /** @noinspection PhpUndefinedClassInspection */ 152 | /** @noinspection OffsetOperationsInspection */ 153 | /** @phpstan-ignore class.notFound, cast.string */ 154 | $rewrittenFile = (string) SoftMocks::getRewrittenFilePath($parts['path'] ?? ''); 155 | } catch (Throwable $throwable) { 156 | $this->logger->warning("Can't get rewritten file path: {$throwable}", $context); 157 | 158 | return ''; 159 | } 160 | if (!$rewrittenFile) { 161 | return ''; 162 | } 163 | if (is_file($rewrittenFile)) { 164 | $file = realpath($rewrittenFile); 165 | if (!$file) { 166 | $this->logger->error("Can't get real path for {$rewrittenFile}", $context); 167 | } 168 | } else { 169 | $this->logger->debug("Rewritten file '{$rewrittenFile}' isn't exists for '{$originalFile}'", $context); 170 | $file = $rewrittenFile; 171 | } 172 | if (!$file) { 173 | return ''; 174 | } 175 | 176 | return 'file://' . $file; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/RunError.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class RunError extends RuntimeException 13 | { 14 | } 15 | -------------------------------------------------------------------------------- /src/Runner.php: -------------------------------------------------------------------------------- 1 | 29 | * 30 | * @phpstan-import-type XdebugProxyConfigArray from Config 31 | */ 32 | class Runner 33 | { 34 | public function run(): void 35 | { 36 | try { 37 | $options = $this->getOptions(); 38 | 39 | if (isset($options['help'])) { 40 | $this->showHelp(); 41 | } 42 | 43 | $configsPath = $this->getConfigsPath($options); 44 | 45 | $logger = $this->getLogger($configsPath); 46 | 47 | $factory = $this->getFactory($configsPath); 48 | $config = $this->getConfig($configsPath, $factory); 49 | $xmlConverter = $factory->createXmlConverter($logger); 50 | $requestPreparers = []; 51 | try { 52 | $requestPreparers = $factory->createRequestPreparers($logger, $config); 53 | } catch (RequestPreparerException $exception) { 54 | $logger->warning("Can't create request preparers: {$exception}"); 55 | } catch (RequestPreparerError $exception) { 56 | $logger->critical("Can't create request preparers: {$exception}"); 57 | $this->end(1); 58 | } 59 | $ideHandler = $factory->createIdeHandler( 60 | $logger, 61 | $config->getIdeServer(), 62 | $xmlConverter, 63 | $requestPreparers 64 | ); 65 | $xdebugHandler = $factory->createXdebugHandler($logger, $xmlConverter, $ideHandler); 66 | $factory->createProxy($logger, $config, $xmlConverter, $ideHandler, $xdebugHandler) 67 | ->run(); 68 | } catch (RunError|UnsupportedFeatureException $error) { 69 | $this->errorFallback(''); 70 | $this->errorFallback('There is error:'); 71 | $this->errorFallback($error->__toString()); 72 | $this->errorFallback(''); 73 | $this->showHelp($error->getCode() ?: 1); 74 | } 75 | } 76 | 77 | #[NoReturn] 78 | protected function showHelp(?int $exitCode = null): void 79 | { 80 | if ($exitCode === null) { 81 | $exitCode = 0; 82 | } 83 | $this->infoFallback('Usage:'); 84 | $this->infoFallback(" {$this->getScriptName()} [options]"); 85 | $this->infoFallback(''); 86 | $this->infoFallback('Mandatory arguments to long options are mandatory for short options too.'); 87 | $this->infoFallback('Options:'); 88 | $this->infoFallback(' -h, --help This help.'); 89 | $this->infoFallback(' -c, --configs=PATH Path to directory with configs:'); 90 | $this->infoFallback(' - config.php: you can customize listen ip and port;'); 91 | $this->infoFallback(' - logger.php: you can customize logger, file should return object, which is instanceof \Psr\Log\LoggerInterface;'); 92 | $this->infoFallback(' - factory.php: you can customize classes, which is used in proxy, file should return object, which is instanceof \Mougrim\XdebugProxy\Factory\Factory.'); 93 | $this->infoFallback(''); 94 | $this->infoFallback('Documentation: .'); 95 | $this->infoFallback(''); 96 | $this->end($exitCode); 97 | } 98 | 99 | /** 100 | * @return array{configs?: string, help?: false} 101 | */ 102 | protected function getOptions(): array 103 | { 104 | $shortToLongOptions = [ 105 | 'c' => 'configs', 106 | 'h' => 'help', 107 | ]; 108 | /** @var array $rawOptions */ 109 | $rawOptions = getopt('c:h', ['configs:', 'help']); 110 | $result = []; 111 | foreach ($shortToLongOptions as $shortOption => $longOption) { 112 | if (isset($rawOptions[$shortOption])) { 113 | $result[$longOption] = $rawOptions[$shortOption]; 114 | } elseif (isset($rawOptions[$longOption])) { 115 | $result[$longOption] = $rawOptions[$longOption]; 116 | } 117 | } 118 | 119 | /** @phpstan-ignore return.type */ 120 | return $result; 121 | } 122 | 123 | /** 124 | * @param array{configs?: string} $options 125 | */ 126 | protected function getConfigsPath(array $options): string 127 | { 128 | $configPath = $options['configs'] ?? __DIR__ . '/../config'; 129 | $realConfigPath = realpath($configPath); 130 | if (!$realConfigPath || !is_dir($realConfigPath)) { 131 | throw new RunError("Wrong config path {$configPath}", 1); 132 | } 133 | $this->infoFallback("Using config path {$realConfigPath}"); 134 | 135 | return $realConfigPath; 136 | } 137 | 138 | protected function getConfig(string $configsPath, Factory $factory): Config 139 | { 140 | $configPath = $configsPath . '/config.php'; 141 | $config = $this->requireConfig($configPath); 142 | if (!is_array($config)) { 143 | throw new RunError("Config '{$configPath}' should return array."); 144 | } 145 | /** @var XdebugProxyConfigArray $config */ 146 | 147 | return $factory->createConfig($config); 148 | } 149 | 150 | protected function getFactory(string $configsPath): Factory 151 | { 152 | $factoryConfigPath = $configsPath . '/factory.php'; 153 | $factory = $this->requireConfig($factoryConfigPath); 154 | if (!$factory instanceof Factory) { 155 | throw new RunError("Factory config '{$factoryConfigPath}' should return Factory object."); 156 | } 157 | 158 | return $factory; 159 | } 160 | 161 | protected function getLogger(string $configsPath): LoggerInterface 162 | { 163 | $loggerConfigPath = $configsPath . '/logger.php'; 164 | $logger = $this->requireConfig($loggerConfigPath); 165 | if (!$logger instanceof LoggerInterface) { 166 | throw new RunError("Logger config '{$loggerConfigPath}' should return LoggerInterface object."); 167 | } 168 | 169 | return $logger; 170 | } 171 | 172 | /** 173 | * @throws RunError 174 | */ 175 | protected function requireConfig(string $path): mixed 176 | { 177 | if (!is_file($path) || !is_readable($path)) { 178 | throw new RunError("Wrong config path {$path}."); 179 | } 180 | return require $path; 181 | } 182 | 183 | public function getScriptName(): string 184 | { 185 | /** @var array $argv */ 186 | $argv = $_SERVER['argv']; 187 | 188 | return $argv[0] ?? 'xdebug-proxy'; 189 | } 190 | 191 | #[NoReturn] 192 | protected function end(int $exitCode): void 193 | { 194 | exit($exitCode); 195 | } 196 | 197 | protected function errorFallback(string $message): void 198 | { 199 | fwrite(STDERR, $message . PHP_EOL); 200 | } 201 | 202 | protected function infoFallback(string $message): void 203 | { 204 | fwrite(STDOUT, $message . PHP_EOL); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/Xml/DomXmlConverter.php: -------------------------------------------------------------------------------- 1 | 42 | */ 43 | class DomXmlConverter implements XmlConverter 44 | { 45 | public function __construct( 46 | protected readonly LoggerInterface $logger, 47 | ) { 48 | } 49 | 50 | protected function getError(int $type): string 51 | { 52 | return match ($type) { 53 | E_ERROR => 'E_ERROR', 54 | E_WARNING => 'E_WARNING', 55 | E_PARSE => 'E_PARSE', 56 | E_NOTICE => 'E_NOTICE', 57 | E_CORE_ERROR => 'E_CORE_ERROR', 58 | E_CORE_WARNING => 'E_CORE_WARNING', 59 | E_COMPILE_ERROR => 'E_COMPILE_ERROR', 60 | E_COMPILE_WARNING => 'E_COMPILE_WARNING', 61 | E_USER_ERROR => 'E_USER_ERROR', 62 | E_USER_WARNING => 'E_USER_WARNING', 63 | E_USER_NOTICE => 'E_USER_NOTICE', 64 | E_STRICT => 'E_STRICT', 65 | E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR', 66 | E_DEPRECATED => 'E_DEPRECATED', 67 | E_USER_DEPRECATED => 'E_USER_DEPRECATED', 68 | default => "[{$type}] Unknown", 69 | }; 70 | } 71 | 72 | /** 73 | * @throws XmlException 74 | */ 75 | public function parse(string $xml): XmlDocument 76 | { 77 | if (!$xml) { 78 | throw new XmlParseException("Can't parse xml: xml must not be empty"); 79 | } 80 | $domDocument = new DOMDocument(); 81 | $result = @$domDocument->loadXML($xml, LIBXML_NONET); 82 | if (!$result) { 83 | $error = error_get_last(); 84 | error_clear_last(); 85 | $message = ''; 86 | if ($error) { 87 | $message = ": [{$this->getError($error['type'])}] {$error['message']} in {$error['file']}:{$error['line']}"; 88 | } 89 | throw new XmlParseException("Can't parse xml{$message}"); 90 | } 91 | 92 | return $this->toDocument($domDocument); 93 | } 94 | 95 | /** 96 | * @throws XmlParseException 97 | */ 98 | protected function toDocument(DOMDocument $domDocument): XmlDocument 99 | { 100 | if (count($domDocument->childNodes) > 1) { 101 | throw new XmlParseException('Too many child nodes in document'); 102 | } 103 | 104 | $root = null; 105 | if ($domDocument->documentElement) { 106 | $root = $this->toContainer($domDocument, $domDocument->documentElement); 107 | } 108 | 109 | return new XmlDocument($domDocument->xmlVersion ?? '', $domDocument->xmlEncoding, $root); 110 | } 111 | 112 | /** 113 | * @throws XmlParseException 114 | */ 115 | protected function toContainer(DOMDocument $domDocument, DOMElement $domElement): XmlContainer 116 | { 117 | $container = new XmlContainer($domElement->tagName); 118 | $xpath = new DOMXPath($domDocument); 119 | $nodes = $xpath->query('namespace::*|attribute::*', $domElement); 120 | if ($nodes) { 121 | foreach ($nodes as /** @var DOMNode $node */ $node) { 122 | if (!$domElement->hasAttribute($node->nodeName)) { 123 | continue; 124 | } 125 | $container->addAttribute($node->nodeName, $node->nodeValue ?? ''); 126 | } 127 | } 128 | $content = ''; 129 | foreach ($domElement->childNodes as /** @var DOMNode $child */ $child) { 130 | if ($child instanceof DOMElement) { 131 | $container->addChild($this->toContainer($domDocument, $child)); 132 | } elseif ($child instanceof DOMText) { 133 | if ($child instanceof DOMCdataSection) { 134 | $container->setIsContentCdata(true); 135 | } 136 | $content .= ($child->nodeValue ?? ''); 137 | } elseif ($child instanceof DOMEntityReference) { 138 | throw new XmlParseException('Xml should be without entity ref nodes'); 139 | } else { 140 | $this->logger->warning( 141 | "Unknown element child node type {$child->nodeType}, skip it", 142 | [ 143 | 'xml' => $domDocument->saveXML(), 144 | ] 145 | ); 146 | } 147 | } 148 | $container->setContent($content); 149 | 150 | return $container; 151 | } 152 | 153 | /** 154 | * @throws XmlException 155 | */ 156 | public function generate(XmlDocument $document): string 157 | { 158 | try { 159 | $domDocument = new DOMDocument($document->getVersion(), $document->getEncoding() ?? ''); 160 | $xmlContainer = $document->getRoot(); 161 | if ($xmlContainer) { 162 | $domElement = $this->toDomElement($domDocument, $xmlContainer); 163 | $domDocument->appendChild($domElement); 164 | } 165 | 166 | $xml = @$domDocument->saveXML(); 167 | 168 | if ($xml === false) { 169 | $error = error_get_last(); 170 | error_clear_last(); 171 | $message = ''; 172 | if ($error) { 173 | $message = ": [{$this->getError($error['type'])}] {$error['message']} in {$error['file']}:{$error['line']}"; 174 | } 175 | 176 | throw new XmlValidateException("Can't generate xml{$message}"); 177 | } 178 | 179 | return $xml; 180 | } catch (DOMException $exception) { 181 | throw new XmlValidateException("Can't generate xml", 0, $exception); 182 | } 183 | } 184 | 185 | /** 186 | * @throws DOMException 187 | */ 188 | protected function toDomElement(DOMDocument $domDocument, XmlContainer $container): DOMElement 189 | { 190 | $domElement = $domDocument->createElement($container->getName()); 191 | if ($container->getContent()) { 192 | if ($container->isContentCdata()) { 193 | $domContent = $domDocument->createCDATASection($container->getContent()); 194 | } else { 195 | $domContent = $domDocument->createTextNode($container->getContent()); 196 | } 197 | if ($domContent) { 198 | $domElement->appendChild($domContent); 199 | } 200 | } 201 | foreach ($container->getAttributes() as $name => $value) { 202 | $domElement->setAttribute($name, $value); 203 | } 204 | foreach ($container->getChildren() as $child) { 205 | $domElement->appendChild($this->toDomElement($domDocument, $child)); 206 | } 207 | 208 | return $domElement; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Xml/XmlContainer.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * Actually XmlContainerArray is recursive, but phpstan doesn't support recursive types 11 | * 12 | * @phpstan-type XmlContainerArray array{name: string, attributes: array, content: string, isContentCdata: bool, children: array{string, mixed}} 13 | */ 14 | class XmlContainer 15 | { 16 | /** @var array */ 17 | protected array $attributes = []; 18 | protected string $content = ''; 19 | protected bool $isContentCdata = false; 20 | /** @var XmlContainer[] */ 21 | protected array $children = []; 22 | 23 | public function __construct( 24 | protected readonly string $name, 25 | ) { 26 | } 27 | 28 | public function getName(): string 29 | { 30 | return $this->name; 31 | } 32 | 33 | public function getAttribute(string $name): ?string 34 | { 35 | return $this->attributes[$name] ?? null; 36 | } 37 | 38 | /** 39 | * @return array 40 | */ 41 | public function getAttributes(): array 42 | { 43 | return $this->attributes; 44 | } 45 | 46 | public function addAttribute(string $name, string $value): static 47 | { 48 | $this->attributes[$name] = $value; 49 | 50 | return $this; 51 | } 52 | 53 | /** 54 | * @param array $attributes 55 | */ 56 | public function setAttributes(array $attributes): static 57 | { 58 | $this->attributes = $attributes; 59 | 60 | return $this; 61 | } 62 | 63 | public function getContent(): string 64 | { 65 | return $this->content; 66 | } 67 | 68 | public function setContent(string $content): static 69 | { 70 | $this->content = $content; 71 | 72 | return $this; 73 | } 74 | 75 | public function isContentCdata(): bool 76 | { 77 | return $this->isContentCdata; 78 | } 79 | 80 | public function setIsContentCdata(bool $isContentCdata): static 81 | { 82 | $this->isContentCdata = $isContentCdata; 83 | 84 | return $this; 85 | } 86 | 87 | /** 88 | * @return XmlContainer[] 89 | */ 90 | public function getChildren(): array 91 | { 92 | return $this->children; 93 | } 94 | 95 | public function addChild(XmlContainer $child): static 96 | { 97 | $this->children[] = $child; 98 | 99 | return $this; 100 | } 101 | 102 | /** 103 | * @param XmlContainer[] $children 104 | */ 105 | public function setChildren(array $children): static 106 | { 107 | $this->children = $children; 108 | 109 | return $this; 110 | } 111 | 112 | /** 113 | * @phpstan-return XmlContainerArray 114 | */ 115 | public function toArray(): array 116 | { 117 | $children = []; 118 | foreach ($this->children as $child) { 119 | $children[] = $child->toArray(); 120 | } 121 | 122 | /** @phpstan-ignore return.type */ 123 | return [ 124 | 'name' => $this->name, 125 | 'attributes' => $this->attributes, 126 | 'content' => $this->content, 127 | 'isContentCdata' => $this->isContentCdata, 128 | 'children' => $children, 129 | ]; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Xml/XmlConverter.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | interface XmlConverter 11 | { 12 | /** 13 | * @throws XmlException 14 | */ 15 | public function parse(string $xml): XmlDocument; 16 | 17 | /** 18 | * @throws XmlException 19 | */ 20 | public function generate(XmlDocument $document): string; 21 | } 22 | -------------------------------------------------------------------------------- /src/Xml/XmlDocument.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * @phpstan-import-type XmlContainerArray from XmlContainer 11 | */ 12 | class XmlDocument 13 | { 14 | public function __construct( 15 | protected readonly string $version, 16 | protected readonly ?string $encoding = null, 17 | protected readonly ?XmlContainer $root = null, 18 | ) { 19 | } 20 | 21 | public function getVersion(): string 22 | { 23 | return $this->version; 24 | } 25 | 26 | public function getEncoding(): ?string 27 | { 28 | return $this->encoding; 29 | } 30 | 31 | public function getRoot(): ?XmlContainer 32 | { 33 | return $this->root; 34 | } 35 | 36 | /** 37 | * @return array{version: string, encoding: ?string, root: ?XmlContainerArray} 38 | */ 39 | public function toArray(): array 40 | { 41 | return [ 42 | 'version' => $this->version, 43 | 'encoding' => $this->encoding, 44 | 'root' => $this->root?->toArray(), 45 | ]; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Xml/XmlException.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class XmlException extends Exception 13 | { 14 | } 15 | -------------------------------------------------------------------------------- /src/Xml/XmlParseException.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class XmlParseException extends XmlException 11 | { 12 | } 13 | -------------------------------------------------------------------------------- /src/Xml/XmlValidateException.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class XmlValidateException extends XmlException 11 | { 12 | } 13 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class TestCase extends PHPUnitTestCase 13 | { 14 | } 15 | -------------------------------------------------------------------------------- /tests/Unit/Xml/DomXmlConverterTest.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | class DomXmlConverterTest extends TestCase 26 | { 27 | public function testParseGenerate(): void 28 | { 29 | $xml = ' 30 | Child1Child2SubChild1SubChild2 31 | '; 32 | $converter = new DomXmlConverter($this->createFakeLogger()); 33 | $document = $converter->parse($xml); 34 | 35 | static::assertSame('1.0', $document->getVersion()); 36 | static::assertSame('UTF-8', $document->getEncoding()); 37 | $root = $document->getRoot(); 38 | static::assertNotEmpty($root); 39 | static::assertSame('root', $root->getName()); 40 | static::assertFalse($root->isContentCdata(), "Content shouldn't be cdata"); 41 | static::assertEmpty($root->getContent(), "Content: {$root->getContent()}"); 42 | static::assertEmpty($root->getAttributes()); 43 | static::assertCount(2, $root->getChildren()); 44 | static::assertSame('child', $root->getChildren()[0]->getName()); 45 | static::assertFalse($root->getChildren()[0]->isContentCdata(), "Content shouldn't be cdata"); 46 | static::assertSame('Child1', $root->getChildren()[0]->getContent()); 47 | static::assertSame(['id' => '1'], $root->getChildren()[0]->getAttributes()); 48 | static::assertEmpty($root->getChildren()[0]->getChildren()); 49 | static::assertSame('child', $root->getChildren()[1]->getName()); 50 | static::assertFalse($root->getChildren()[1]->isContentCdata(), "Content shouldn't be cdata"); 51 | static::assertSame('Child2', $root->getChildren()[1]->getContent()); 52 | static::assertSame(['id' => '2'], $root->getChildren()[1]->getAttributes()); 53 | $subChildren = $root->getChildren()[1]->getChildren(); 54 | static::assertCount(2, $subChildren); 55 | static::assertSame('sub-child', $subChildren[0]->getName()); 56 | static::assertFalse($subChildren[0]->isContentCdata(), "Content shouldn't be cdata"); 57 | static::assertSame('SubChild1', $subChildren[0]->getContent()); 58 | static::assertEmpty($subChildren[0]->getAttributes()); 59 | static::assertEmpty($subChildren[0]->getChildren()); 60 | static::assertSame('sub-child', $subChildren[1]->getName()); 61 | static::assertFalse($subChildren[1]->isContentCdata(), "Content shouldn't be cdata"); 62 | static::assertSame('SubChild2', $subChildren[1]->getContent()); 63 | static::assertEmpty($subChildren[1]->getAttributes()); 64 | static::assertEmpty($subChildren[1]->getChildren()); 65 | static::assertSame( 66 | [ 67 | 'version' => '1.0', 68 | 'encoding' => 'UTF-8', 69 | 'root' => [ 70 | 'name' => 'root', 71 | 'attributes' => [], 72 | 'content' => '', 73 | 'isContentCdata' => false, 74 | 'children' => [ 75 | [ 76 | 'name' => 'child', 77 | 'attributes' => [ 78 | 'id' => '1', 79 | ], 80 | 'content' => 'Child1', 81 | 'isContentCdata' => false, 82 | 'children' => [], 83 | ], 84 | [ 85 | 'name' => 'child', 86 | 'attributes' => [ 87 | 'id' => '2', 88 | ], 89 | 'content' => 'Child2', 90 | 'isContentCdata' => false, 91 | 'children' => [ 92 | [ 93 | 'name' => 'sub-child', 94 | 'attributes' => [], 95 | 'content' => 'SubChild1', 96 | 'isContentCdata' => false, 97 | 'children' => [], 98 | ], 99 | [ 100 | 'name' => 'sub-child', 101 | 'attributes' => [], 102 | 'content' => 'SubChild2', 103 | 'isContentCdata' => false, 104 | 'children' => [], 105 | ], 106 | ], 107 | ], 108 | ], 109 | ], 110 | ], 111 | $document->toArray() 112 | ); 113 | 114 | static::assertSame($xml, $converter->generate($document)); 115 | } 116 | 117 | public function testParseGenerateEscaping(): void 118 | { 119 | $xml = ' 120 | </root><root>xss 121 | '; 122 | $converter = new DomXmlConverter($this->createFakeLogger()); 123 | $document = $converter->parse($xml); 124 | static::assertSame('1.0', $document->getVersion()); 125 | static::assertSame('UTF-8', $document->getEncoding()); 126 | $root = $document->getRoot(); 127 | static::assertSame('root', $root?->getName()); 128 | static::assertFalse($root->isContentCdata(), "Content shouldn't be cdata"); 129 | static::assertSame('xss', $root->getContent()); 130 | static::assertSame(['attribute' => '">xss'], $root->getAttributes()); 131 | static::assertEmpty($root->getChildren()); 132 | static::assertSame( 133 | [ 134 | 'version' => '1.0', 135 | 'encoding' => 'UTF-8', 136 | 'root' => [ 137 | 'name' => 'root', 138 | 'attributes' => [ 139 | 'attribute' => '">xss', 140 | ], 141 | 'content' => 'xss', 142 | 'isContentCdata' => false, 143 | 'children' => [], 144 | ], 145 | ], 146 | $document->toArray() 147 | ); 148 | static::assertSame($xml, $converter->generate($document)); 149 | } 150 | 151 | public function testWrongNameDecoding(): void 152 | { 153 | $xml = ' 154 | <<rootattribute="value1"> attribute="value2">Content 155 | '; 156 | $converter = new DomXmlConverter($this->createFakeLogger()); 157 | $this->expectException(XmlParseException::class); 158 | $this->expectExceptionMessage("Can't parse xml"); 159 | $converter->parse($xml); 160 | } 161 | 162 | public function testParseEmptyMessage(): void 163 | { 164 | $converter = new DomXmlConverter($this->createFakeLogger()); 165 | $this->expectException(XmlParseException::class); 166 | $this->expectExceptionMessage("Can't parse xml"); 167 | $converter->parse(''); 168 | } 169 | 170 | public function testNameValidate(): void 171 | { 172 | $root = new XmlContainer(htmlspecialchars('', ENT_XML1 | ENT_QUOTES)); 173 | $document = new XmlDocument('1.0', 'UTF-8', $root); 174 | $converter = new DomXmlConverter($this->createFakeLogger()); 175 | $this->expectException(XmlValidateException::class); 176 | $this->expectExceptionMessage("Can't generate xml"); 177 | $converter->generate($document); 178 | } 179 | 180 | public function testAttributeNameValidate(): void 181 | { 182 | $root = (new XmlContainer('root')) 183 | ->addAttribute( 184 | htmlspecialchars('>XSSgenerate($document); 192 | } 193 | 194 | /** 195 | * @see https://www.owasp.org/index.php/XML_External_Entity_(XXE)_Processing 196 | */ 197 | public function testXxe(): void 198 | { 199 | /** @noinspection CheckDtdRefs */ 200 | $xml = ' 201 | 203 | ]> 204 | &harmless; 205 | '; 206 | 207 | $converter = new DomXmlConverter($this->createFakeLogger()); 208 | $this->expectException(XmlParseException::class); 209 | $this->expectExceptionMessageMatches( 210 | '/^(?:Xml should be without entity ref nodes|Too many child nodes in document)$/' 211 | ); 212 | $converter->parse($xml); 213 | } 214 | 215 | public function testXdebugInit(): void 216 | { 217 | $xml = ' 218 | 219 | '; 220 | 221 | $converter = new DomXmlConverter($this->createFakeLogger()); 222 | $document = $converter->parse($xml); 223 | 224 | static::assertSame('1.0', $document->getVersion()); 225 | static::assertSame('iso-8859-1', $document->getEncoding()); 226 | $root = $document->getRoot(); 227 | 228 | static::assertNotEmpty($root); 229 | static::assertSame('init', $root->getName()); 230 | static::assertFalse($root->isContentCdata(), "Content shouldn't be cdata"); 231 | static::assertEmpty($root->getContent(), "Content: {$root->getContent()}"); 232 | static::assertSame( 233 | [ 234 | 'xmlns:xdebug' => 'http://xdebug.org/dbgp/xdebug', 235 | 'xmlns' => 'urn:debugger_protocol_v1', 236 | 'fileuri' => 'file:///path/to/file.php', 237 | 'language' => 'PHP', 238 | 'xdebug:language_version' => '7.1.15-1+os1.02.3+some.site.org+1', 239 | 'protocol_version' => '1.0', 240 | 'appid' => '15603', 241 | 'idekey' => 'idekey', 242 | ], 243 | $root->getAttributes() 244 | ); 245 | static::assertCount(4, $root->getChildren()); 246 | 247 | $children = $root->getChildren(); 248 | 249 | static::assertSame('engine', $children[0]->getName()); 250 | static::assertTrue($children[0]->isContentCdata(), 'Content should be cdata'); 251 | static::assertSame('Xdebug', $children[0]->getContent()); 252 | static::assertSame( 253 | [ 254 | 'version' => '2.6.0', 255 | ], 256 | $children[0]->getAttributes() 257 | ); 258 | static::assertCount(0, $children[0]->getChildren()); 259 | 260 | static::assertSame('author', $children[1]->getName()); 261 | static::assertTrue($children[1]->isContentCdata(), 'Content should be cdata'); 262 | static::assertSame('Derick Rethans', $children[1]->getContent()); 263 | static::assertEmpty($children[1]->getAttributes()); 264 | static::assertCount(0, $children[1]->getChildren()); 265 | 266 | static::assertSame('url', $children[2]->getName()); 267 | static::assertTrue($children[2]->isContentCdata(), 'Content should be cdata'); 268 | static::assertSame('http://xdebug.org', $children[2]->getContent()); 269 | static::assertEmpty($children[2]->getAttributes()); 270 | static::assertCount(0, $children[2]->getChildren()); 271 | 272 | static::assertSame('copyright', $children[3]->getName()); 273 | static::assertTrue($children[3]->isContentCdata(), 'Content should be cdata'); 274 | static::assertSame('Copyright (c) 2002-2018 by Derick Rethans', $children[3]->getContent()); 275 | static::assertEmpty($children[3]->getAttributes()); 276 | static::assertCount(0, $children[3]->getChildren()); 277 | 278 | static::assertSame( 279 | [ 280 | 'version' => '1.0', 281 | 'encoding' => 'iso-8859-1', 282 | 'root' => [ 283 | 'name' => 'init', 284 | 'attributes' => [ 285 | 'xmlns:xdebug' => 'http://xdebug.org/dbgp/xdebug', 286 | 'xmlns' => 'urn:debugger_protocol_v1', 287 | 'fileuri' => 'file:///path/to/file.php', 288 | 'language' => 'PHP', 289 | 'xdebug:language_version' => '7.1.15-1+os1.02.3+some.site.org+1', 290 | 'protocol_version' => '1.0', 291 | 'appid' => '15603', 292 | 'idekey' => 'idekey', 293 | ], 294 | 'content' => '', 295 | 'isContentCdata' => false, 296 | 'children' => [ 297 | [ 298 | 'name' => 'engine', 299 | 'attributes' => [ 300 | 'version' => '2.6.0', 301 | ], 302 | 'content' => 'Xdebug', 303 | 'isContentCdata' => true, 304 | 'children' => [], 305 | ], 306 | [ 307 | 'name' => 'author', 308 | 'attributes' => [], 309 | 'content' => 'Derick Rethans', 310 | 'isContentCdata' => true, 311 | 'children' => [], 312 | ], 313 | [ 314 | 'name' => 'url', 315 | 'attributes' => [], 316 | 'content' => 'http://xdebug.org', 317 | 'isContentCdata' => true, 318 | 'children' => [], 319 | ], 320 | [ 321 | 'name' => 'copyright', 322 | 'attributes' => [], 323 | 'content' => 'Copyright (c) 2002-2018 by Derick Rethans', 324 | 'isContentCdata' => true, 325 | 'children' => [], 326 | ], 327 | ], 328 | ], 329 | ], 330 | $document->toArray() 331 | ); 332 | 333 | static::assertSame($xml, $converter->generate($document)); 334 | } 335 | 336 | public function testXdebugMessage(): void 337 | { 338 | $xml = ' 339 | '; 340 | 341 | $converter = new DomXmlConverter($this->createFakeLogger()); 342 | $document = $converter->parse($xml); 343 | 344 | static::assertSame('1.0', $document->getVersion()); 345 | static::assertSame('iso-8859-1', $document->getEncoding()); 346 | $root = $document->getRoot(); 347 | 348 | static::assertNotEmpty($root); 349 | static::assertSame('response', $root->getName()); 350 | static::assertFalse($root->isContentCdata(), "Content shouldn't be cdata"); 351 | static::assertEmpty($root->getContent(), "Content: {$root->getContent()}"); 352 | static::assertSame( 353 | [ 354 | 'xmlns:xdebug' => 'http://xdebug.org/dbgp/xdebug', 355 | 'xmlns' => 'urn:debugger_protocol_v1', 356 | 'command' => 'step_into', 357 | 'transaction_id' => '8', 358 | 'status' => 'break', 359 | 'reason' => 'ok', 360 | ], 361 | $root->getAttributes() 362 | ); 363 | static::assertCount(1, $root->getChildren()); 364 | 365 | $children = $root->getChildren(); 366 | 367 | static::assertSame('xdebug:message', $children[0]->getName()); 368 | static::assertFalse($children[0]->isContentCdata(), 'Content shouldn\'t be cdata'); 369 | static::assertEmpty($children[0]->getContent(), "Content: {$children[0]->getContent()}"); 370 | static::assertSame( 371 | [ 372 | 'filename' => 'file:///home/mougrim/Private/development/mougrim/php-xdebug-proxy/bin/test.php', 373 | 'lineno' => '11', 374 | ], 375 | $children[0]->getAttributes() 376 | ); 377 | static::assertCount(0, $children[0]->getChildren()); 378 | 379 | static::assertSame( 380 | [ 381 | 'version' => '1.0', 382 | 'encoding' => 'iso-8859-1', 383 | 'root' => [ 384 | 'name' => 'response', 385 | 'attributes' => [ 386 | 'xmlns:xdebug' => 'http://xdebug.org/dbgp/xdebug', 387 | 'xmlns' => 'urn:debugger_protocol_v1', 388 | 'command' => 'step_into', 389 | 'transaction_id' => '8', 390 | 'status' => 'break', 391 | 'reason' => 'ok', 392 | ], 393 | 'content' => '', 394 | 'isContentCdata' => false, 395 | 'children' => [ 396 | [ 397 | 'name' => 'xdebug:message', 398 | 'attributes' => [ 399 | 'filename' => 'file:///home/mougrim/Private/development/mougrim/php-xdebug-proxy/bin/test.php', 400 | 'lineno' => '11', 401 | ], 402 | 'content' => '', 403 | 'isContentCdata' => false, 404 | 'children' => [], 405 | ], 406 | ], 407 | ], 408 | ], 409 | $document->toArray() 410 | ); 411 | } 412 | 413 | protected function createFakeLogger(): LoggerInterface 414 | { 415 | return (new Logger('fake')) 416 | ->pushHandler(new NullHandler()); 417 | } 418 | } 419 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | 8 | namespace Tests\Mougrim\XdebugProxy; 9 | 10 | use function dirname; 11 | 12 | require dirname(__DIR__) . '/vendor/autoload.php'; 13 | --------------------------------------------------------------------------------