├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── entrypoint_nightly.yml │ ├── entrypoint_prerelease.yml │ ├── entrypoint_pull_request.yml │ ├── entrypoint_release.yml │ └── sub_testing.yml ├── .gitignore ├── .gitmodules ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── exegol.py ├── exegol ├── __init__.py ├── __main__.py ├── config │ ├── ConstantConfig.py │ ├── DataCache.py │ ├── EnvInfo.py │ ├── UserConfig.py │ └── __init__.py ├── console │ ├── ConsoleFormat.py │ ├── ExegolProgress.py │ ├── ExegolPrompt.py │ ├── LayerTextColumn.py │ ├── MetaGitProgress.py │ ├── TUI.py │ ├── __init__.py │ └── cli │ │ ├── ExegolCompleter.py │ │ ├── ParametersManager.py │ │ ├── __init__.py │ │ └── actions │ │ ├── Command.py │ │ ├── ExegolParameters.py │ │ ├── GenericParameters.py │ │ └── __init__.py ├── exceptions │ ├── ExegolExceptions.py │ └── __init__.py ├── manager │ ├── ExegolController.py │ ├── ExegolManager.py │ ├── UpdateManager.py │ └── __init__.py ├── model │ ├── CacheModels.py │ ├── ContainerConfig.py │ ├── ExegolContainer.py │ ├── ExegolContainerTemplate.py │ ├── ExegolImage.py │ ├── ExegolModules.py │ ├── MetaImages.py │ ├── SelectableInterface.py │ └── __init__.py └── utils │ ├── ContainerLogStream.py │ ├── DataFileUtils.py │ ├── DockerUtils.py │ ├── ExeLog.py │ ├── FsUtils.py │ ├── GitUtils.py │ ├── GuiUtils.py │ ├── MetaSingleton.py │ ├── WebUtils.py │ ├── __init__.py │ ├── argParse.py │ └── imgsync │ ├── ImageScriptSync.py │ ├── __init__.py │ ├── entrypoint.sh │ └── spawn.sh ├── pyproject.toml ├── requirements.txt ├── setup.py.old └── tests ├── __init__.py └── exegol_test.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Handle python file as text 5 | *.py text 6 | 7 | # Every shell file must be cloned with LF line ending 8 | *.sh text eol=lf 9 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: nwodtuhs 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug report [WRAPPER] 2 | description: Report a bug in Exegol WRAPPER to help us improve it 3 | title: "" 4 | labels: 5 | - bug 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Verification before publication: 11 | 12 | - You are creating a bug report in the Exegol **WRAPPER** repository (the exegol command)! 13 | > If your problem concerns the environment, tools or other elements specific to the Exegol **IMAGE**, please open your issue on the [Exegol-images](https://github.com/ThePorgs/Exegol-images) repository. 14 | - Check that there is not already a issue for the **same** problem. 15 | - Some problems are already well known and can be found in the **documentation** or on the Exegol **Discord**. 16 | - type: textarea 17 | attributes: 18 | label: Describe the bug 19 | description: | 20 | A clear and concise description of what the bug is. 21 | 22 | Include both the current behavior (what you are seeing) as well as what you expected to happen. 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Steps To Reproduce 28 | description: Steps to reproduce the behavior. 29 | placeholder: | 30 | 1. Use a specific configuration (if applicable) '...' 31 | 2. Run Exegol command `exegol ...` 32 | 3. Interactive choice (if applicable) '....' 33 | 4. Error message 34 | validations: 35 | required: false 36 | - type: textarea 37 | attributes: 38 | label: Exegol Wrapper Version 39 | description: | 40 | Paste output of `exegol version -vvv`. 41 | placeholder: | 42 | Paste your output here or a screenshot. 43 | render: Text 44 | validations: 45 | required: True 46 | - type: dropdown 47 | attributes: 48 | label: Host OS 49 | description: Select your host OS 50 | options: 51 | - Linux 52 | - MacOS 53 | - Windows 10 and before 54 | - Windows 11 55 | validations: 56 | required: false 57 | - type: textarea 58 | attributes: 59 | label: Configuration of the concerned container 60 | description: | 61 | Paste output of `exegol info -v <container_name>` (if applicable). 62 | placeholder: | 63 | Paste your output here or a screenshot. 64 | render: Text 65 | validations: 66 | required: False 67 | - type: textarea 68 | attributes: 69 | label: Execution logs in debug mode 70 | description: | 71 | Run your exegol command in debug mod with the parameter `-vvv` and copy/paste the full output: 72 | placeholder: | 73 | Paste your execution logs here 74 | render: Text 75 | validations: 76 | required: true 77 | - type: textarea 78 | attributes: 79 | label: Exception 80 | description: | 81 | If applicable, copy paste your exception stack: 82 | placeholder: | 83 | Paste your stacktrace here 84 | render: Text 85 | validations: 86 | required: false 87 | - type: textarea 88 | attributes: 89 | label: Anything else? 90 | description: | 91 | Links? References? Screenshot? Anything that will give us more context about the issue you are encountering! 92 | 93 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 94 | validations: 95 | required: false 96 | - type: markdown 97 | attributes: 98 | value: "Thanks for completing our form!" 99 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Exegol Community Discord 4 | url: https://discord.gg/cXThyp7D6P 5 | about: 'Use the #open-a-ticket channel to ask your question.' 6 | - name: Read the Exegol documentation 7 | url: https://exegol.readthedocs.io/ 8 | about: 'An answer to your question may already be available in the documentation!' 9 | - name: Create an issue regarding the Exegol [IMAGE] project 10 | url: https://github.com/ThePorgs/Exegol-images/issues 11 | about: 'For the creation of issue related to the Exegol IMAGE project.' 12 | - name: Create an issue regarding the Exegol [RESOURCES] project 13 | url: https://github.com/ThePorgs/Exegol-resources/issues 14 | about: 'For the creation of issue related to the Exegol RESOURCES project.' 15 | - name: Create an issue regarding the Exegol [DOCS] project 16 | url: https://github.com/ThePorgs/Exegol-docs/issues 17 | about: 'For the creation of issue related to the Exegol DOCS project.' 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request [WRAPPER] 2 | description: Suggest an idea for the Exegol WRAPPER command 3 | labels: 4 | - enhancement 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Verification before publication: 10 | 11 | - You are creating a feature request in the Exegol **WRAPPER** repository (the exegol command)! 12 | > If your request concerns the exegol environment, tools or other elements specific to the Exegol **IMAGE**, please open your issue on the [Exegol-images](https://github.com/ThePorgs/Exegol-images) repository. 13 | - Check that there is not already your feature request on the Exegol **roadmap** (can be consulted in the **documentation** or on the Exegol **Discord**) 14 | 15 | - type: textarea 16 | attributes: 17 | label: The needs 18 | description: Is your feature request related to a problem? Please describe. 19 | placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 20 | validations: 21 | required: false 22 | - type: textarea 23 | attributes: 24 | label: Description 25 | description: Describe the solution you'd like 26 | placeholder: A clear and concise description of what you want to happen. 27 | validations: 28 | required: true 29 | - type: textarea 30 | attributes: 31 | label: Alternatives 32 | description: Describe alternatives you've considered 33 | placeholder: A clear and concise description of any alternative solutions or features you've considered. 34 | validations: 35 | required: false 36 | - type: textarea 37 | attributes: 38 | label: Additional context 39 | description: Add any other context or screenshots about the feature request here. 40 | validations: 41 | required: false 42 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | > A description of your PR, what it brings or corrects. Don't forget to configure your PR to the dev branch (cf. https://exegol.readthedocs.io/en/latest/community/contributors.html) 4 | 5 | # Related issues 6 | 7 | > If your PR responds to an issue for a bug fix or feature request, make sure to includes references to the issues (e.g. "fixes #xxxx"). 8 | 9 | # Point of attention 10 | 11 | > Things you are not sure about that deserve special attention if you have doubts or questions. 12 | -------------------------------------------------------------------------------- /.github/workflows/entrypoint_nightly.yml: -------------------------------------------------------------------------------- 1 | name: Nightly build 2 | 3 | on: 4 | push: 5 | branches: 6 | - "dev" 7 | #paths: 8 | # - "**.py" 9 | 10 | jobs: 11 | test: 12 | name: Python tests and checks 13 | uses: ./.github/workflows/sub_testing.yml 14 | 15 | build-n-publish: 16 | name: Build and publish Python 🐍 distributions to TestPyPI 📦 17 | runs-on: ubuntu-latest 18 | environment: nightly 19 | permissions: 20 | # IMPORTANT: this permission is mandatory for trusted publishing 21 | id-token: write 22 | needs: test 23 | steps: 24 | - uses: actions/checkout@main 25 | with: 26 | submodules: true 27 | - name: Set up PDM 28 | uses: pdm-project/setup-pdm@v4 29 | with: 30 | python-version: "3.12" 31 | - name: Build Exegol 32 | run: pdm build 33 | - name: Publish distribution 📦 to Test PyPI 34 | run: pdm publish --no-build --repository https://test.pypi.org/legacy/ --skip-existing 35 | -------------------------------------------------------------------------------- /.github/workflows/entrypoint_prerelease.yml: -------------------------------------------------------------------------------- 1 | name: Pre-release PR 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "master" 7 | paths-ignore: # not always respected. See https://github.com/actions/runner/issues/2324#issuecomment-1703345084 8 | - ".github/**" 9 | - "**.md" 10 | 11 | # creating a separate concurrency group for each PR 12 | # so that our "PR checks" are always running for the latest commit in the PR 13 | # and as PRs are updated we want to make sure "in progress" jobs are killed so we don't waste resources 14 | concurrency: 15 | group: ${{ github.ref }} 16 | cancel-in-progress: true 17 | 18 | 19 | jobs: 20 | preprod_test: 21 | name: Pre-prod code testing 22 | runs-on: ubuntu-latest 23 | if: github.event.pull_request.draft == false 24 | steps: 25 | - uses: actions/checkout@main 26 | with: 27 | submodules: false 28 | - name: Set up Python 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: "3.12" 32 | - name: Find spawn.sh script version 33 | run: egrep '^# Spawn Version:[0-9ab]+$' ./exegol/utils/imgsync/spawn.sh | cut -d ':' -f2 34 | - name: Check for prod readiness of spawn.sh script version 35 | run: egrep '^# Spawn Version:[0-9]+$' ./exegol/utils/imgsync/spawn.sh 36 | - name: Check package version (alpha and beta version cannot be released) 37 | run: python3 -c 'from exegol.config.ConstantConfig import ConstantConfig; print(ConstantConfig.version); exit(any(c in ConstantConfig.version for c in ["a", "b"]))' 38 | 39 | code_test: 40 | name: Python tests and checks 41 | needs: preprod_test 42 | uses: ./.github/workflows/sub_testing.yml 43 | -------------------------------------------------------------------------------- /.github/workflows/entrypoint_pull_request.yml: -------------------------------------------------------------------------------- 1 | name: Ext. PR tests & int. branches pushes 2 | 3 | # for external contribution pull requests, 4 | # and internal pushes to specific branches (!= dev) 5 | 6 | on: 7 | pull_request: 8 | branches-ignore: 9 | - "master" 10 | paths-ignore: # not always respected. See https://github.com/actions/runner/issues/2324#issuecomment-1703345084 11 | - ".github/**" 12 | - "**.md" 13 | push: 14 | branches-ignore: 15 | - "dev" 16 | - "master" 17 | paths-ignore: 18 | - ".github/**" 19 | - "**.md" 20 | 21 | # todo add whitelist paths like entrypoint_nightly.yml 22 | 23 | # creating a separate concurrency group for each PR 24 | # so that our "PR checks" are always running for the latest commit in the PR 25 | # and as PRs are updated we want to make sure "in progress" jobs are killed so we don't waste resources 26 | concurrency: 27 | group: ${{ github.ref }} 28 | cancel-in-progress: true 29 | 30 | 31 | jobs: 32 | test: 33 | name: Python tests and checks 34 | uses: ./.github/workflows/sub_testing.yml 35 | -------------------------------------------------------------------------------- /.github/workflows/entrypoint_release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+' 7 | 8 | jobs: 9 | test: 10 | name: Python tests and checks 11 | uses: ./.github/workflows/sub_testing.yml 12 | 13 | build-n-publish: 14 | name: Build and publish Python 🐍 distributions to PyPI 📦 15 | runs-on: ubuntu-latest 16 | environment: release 17 | permissions: 18 | # IMPORTANT: this permission is mandatory for trusted publishing 19 | id-token: write 20 | needs: test 21 | steps: 22 | - uses: actions/checkout@main 23 | with: 24 | submodules: true 25 | - name: Set up PDM 26 | uses: pdm-project/setup-pdm@v4 27 | with: 28 | python-version: "3.12" 29 | - name: Build Exegol 30 | run: pdm build 31 | - name: Publish distribution 📦 to Test PyPI 32 | run: pdm publish --no-build --repository https://test.pypi.org/legacy/ --skip-existing 33 | - name: Publish distribution 📦 to PyPI (prod) 34 | run: pdm publish --no-build 35 | -------------------------------------------------------------------------------- /.github/workflows/sub_testing.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | testing: 8 | name: Code testing 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@main 12 | with: 13 | submodules: false 14 | - name: Set up PDM 15 | uses: pdm-project/setup-pdm@v4 16 | with: 17 | python-version: "3.12" 18 | - name: Install requirements 19 | run: pdm update && pdm sync -d -G testing 20 | - name: Run code analysis (package) 21 | run: pdm run -v mypy ./exegol/ --ignore-missing-imports --check-untyped-defs --pretty # TODO add --disallow-untyped-defs 22 | - name: Run code analysis (source) 23 | run: pdm run -v mypy ./exegol.py --ignore-missing-imports --check-untyped-defs --pretty # TODO add --disallow-untyped-defs 24 | - name: Find spawn.sh script version 25 | run: egrep '^# Spawn Version:[0-9ab]+$' ./exegol/utils/imgsync/spawn.sh | cut -d ':' -f2 26 | - name: Pre-commit checks 27 | run: pdm run -v pre-commit run --all-files --hook-stage pre-commit 28 | 29 | compatibility: 30 | name: Compatibility checks 31 | runs-on: ubuntu-latest 32 | needs: testing 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 37 | os: [win32, linux, darwin] 38 | steps: 39 | - uses: actions/checkout@main 40 | with: 41 | submodules: false 42 | - name: Set up PDM 43 | uses: pdm-project/setup-pdm@v4 44 | with: 45 | python-version: "3.12" 46 | - name: Install requirements 47 | run: pdm update && pdm sync -d -G testing 48 | - name: Check python compatibility for ${{ matrix.os }}/${{ matrix.version }} 49 | run: pdm run -v mypy ./exegol.py --ignore-missing-imports --check-untyped-defs --python-version ${{ matrix.version }} --platform ${{ matrix.os }} 50 | 51 | # wrapper-testing: 52 | # name: Wrapper tests 53 | # needs: testing 54 | # runs-on: ${{ matrix.os }} 55 | # strategy: 56 | # matrix: 57 | # python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] 58 | # os: [ ubuntu-latest, macOS-latest, windows-latest ] 59 | # 60 | # steps: 61 | # - uses: actions/checkout@v4 62 | # - name: Set up PDM 63 | # uses: pdm-project/setup-pdm@v4 64 | # with: 65 | # python-version: ${{ matrix.python-version }} 66 | # 67 | # - name: Install dependencies 68 | # run: pdm update && pdm sync -d -G testing 69 | # - name: Run Tests 70 | # run: pdm run -v pytest tests 71 | 72 | build-n-install: 73 | name: Build and install Python 🐍 package 📦 74 | runs-on: ubuntu-latest 75 | needs: testing 76 | steps: 77 | - uses: actions/checkout@main 78 | with: 79 | submodules: true 80 | - name: Set up PDM 81 | uses: pdm-project/setup-pdm@v4 82 | with: 83 | python-version: "3.12" 84 | - name: Build Exegol 85 | run: pdm build 86 | - name: Create testing venv 87 | run: pdm venv create -n vtest-source --with-pip && pdm venv create -n vtest-wheel --with-pip 88 | - name: Install pip source package 89 | run: pdm run --venv vtest-source -v pip install ./dist/*.tar.gz 90 | - name: Install pip source package 91 | run: pdm run --venv vtest-wheel -v pip install ./dist/*.tar.gz 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | vtest/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # Personal shared volume 105 | shared-data-volumes/ 106 | shared-resources/ 107 | 108 | # PyCharm and Python workspace 109 | .idea/ 110 | 111 | # Build logs for debugging 112 | .build.log 113 | 114 | # PDM 115 | pdm.lock 116 | .pdm-build 117 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "exegol-images"] 2 | path = exegol-images 3 | url = https://github.com/ThePorgs/Exegol-images.git 4 | branch = main 5 | [submodule "exegol-resources"] 6 | path = exegol-resources 7 | url = https://github.com/ThePorgs/Exegol-resources 8 | branch = main 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | default_install_hook_types: [ pre-commit, pre-push ] 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v5.0.0 7 | hooks: 8 | - id: trailing-whitespace 9 | exclude: 'exegol/config/UserConfig.py' 10 | - id: end-of-file-fixer 11 | - id: check-yaml 12 | - id: check-toml 13 | - id: check-shebang-scripts-are-executable 14 | - id: check-case-conflict 15 | - id: name-tests-test 16 | - id: check-illegal-windows-names 17 | - id: mixed-line-ending 18 | args: 19 | - --fix=lf 20 | - id: check-added-large-files 21 | args: 22 | - --maxkb=200 # Max 200kb file can be added 23 | - repo: https://github.com/pre-commit/pygrep-hooks 24 | rev: v1.10.0 25 | hooks: 26 | - id: python-no-eval 27 | - id: python-use-type-annotations 28 | - repo: https://github.com/shellcheck-py/shellcheck-py 29 | rev: v0.10.0.1 30 | hooks: 31 | - id: shellcheck 32 | - repo: https://github.com/pre-commit/mirrors-mypy 33 | rev: 'v1.15.0' 34 | hooks: 35 | - id: mypy 36 | stages: [pre-push] 37 | additional_dependencies: ["types-PyYAML", "types-requests", "types-tzlocal"] 38 | exclude: "exegol.py" 39 | args: 40 | - --ignore-missing-imports 41 | - --check-untyped-defs 42 | - --pretty 43 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributors documentation 2 | Check the full contributors (up-to-date) documentation here: https://exegol.readthedocs.io/en/dev/community/contributors.html 3 | 4 | # Wrapper & images (legacy) 5 | - the `master` branch is the stable version. Only Pull Requests are allowed on this branch. 6 | - the `dev` branch is used for active development. This is the bleeding-edge version, but is sometimes not as stable as the `master` (depending on the development cycle). 7 | - the `Exegol` repository includes the exegol.py wrapper code base, and features a `exegol-docker-build` submodule tracking [Exegol-images](https://github.com/ThePorgs/Exegol-images). 8 | - changes to the images/dockerfiles/tools/installs must be done on the [Exegol-images](https://github.com/ThePorgs/Exegol-images) repo. 9 | - by default, the wrapper pulls the latest DockerHub pre-built image for the install and updates 10 | - DockerHub automatic builds are configured as follows 11 | - `nightly` image is built using the base Dockerfile whenever a commit is made on [Exegol-images](https://github.com/ThePorgs/Exegol-images) `dev` branch. 12 | - `full` image is built using the base Dockerfile whenever a new tag is pushed on [Exegol-images](https://github.com/ThePorgs/Exegol-images). 13 | - `ad`, `osint`, `web` and `light` images are built using specific Dockerfiles whenever a new tag is pushed on [Exegol-images](https://github.com/ThePorgs/Exegol-images). 14 | - if you want to locally build your image with your changes, run `exegol install local`. If you have local changes to the dockerfiles, they won't be overwritten. 15 | - any addition/question/edit/pull request to the wrapper? Feel free to raise issues on this repo, or contribute on the dev branch! 16 | - any addition/question/edit/pull request to the docker images? GOTO [Exegol-images](https://github.com/ThePorgs/Exegol-images). 17 | 18 | Any other idea that falls outside this scope? 19 | Any question that is left unanswered? 20 | Feel free to reach out, I'll be happy to help and improve things, Exegol is a community-driven toolkit :rocket: 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | <div align="center"> 2 | <img alt="latest commit on master" width="600" src="https://raw.githubusercontent.com/ThePorgs/Exegol-docs/main/.assets/rounded_social_preview.png"> 3 | <br><br> 4 | <a target="_blank" rel="noopener noreferrer" href="https://pypi.org/project/Exegol" title=""><img src="https://img.shields.io/pypi/v/Exegol?color=informational" alt="pip package version"></a> 5 | <img alt="Python3.7" src="https://img.shields.io/badge/Python-3.7+-informational"> 6 | <img alt="latest commit on master" src="https://img.shields.io/docker/pulls/nwodtuhs/exegol.svg?label=downloads"> 7 | <br><br> 8 | <img alt="latest commit on master" src="https://img.shields.io/github/last-commit/ThePorgs/Exegol/master?label=latest%20release"> 9 | <img alt="latest commit on dev" src="https://img.shields.io/github/last-commit/ThePorgs/Exegol/dev?label=latest%20dev"> 10 | <br><br> 11 | <img alt="current version" src="https://img.shields.io/badge/linux-supported-success"> 12 | <img alt="current version" src="https://img.shields.io/badge/windows-supported-success"> 13 | <img alt="current version" src="https://img.shields.io/badge/mac-supported-success"> 14 | <br> 15 | <img alt="amd64" src="https://img.shields.io/badge/amd64%20(x86__64)-supported-success"> 16 | <img alt="arm64" src="https://img.shields.io/badge/arm64%20(aarch64)-supported-success"> 17 | <br><br> 18 | <a target="_blank" rel="noopener noreferrer" href="https://twitter.com/intent/follow?screen_name=_nwodtuhs" title="Follow"><img src="https://img.shields.io/twitter/follow/_nwodtuhs?label=Shutdown&style=social" alt="Twitter Shutdown"></a> 19 | <a target="_blank" rel="noopener noreferrer" href="https://twitter.com/intent/follow?screen_name=Dramelac_" title="Follow"><img src="https://img.shields.io/twitter/follow/Dramelac_?label=Dramelac&style=social" alt="Twitter Dramelac"></a> 20 | <br> 21 | <a target="_blank" rel="noopener noreferrer" href="https://www.blackhat.com/eu-22/arsenal/schedule/index.html#exegol-29180" title="Schedule"> 22 | <img alt="Black Hat Europe 2022" src="https://img.shields.io/badge/Black%20Hat%20Arsenal-Europe%202022-blueviolet"> 23 | </a> 24 | <a target="_blank" rel="noopener noreferrer" href="https://www.blackhat.com/asia-23/arsenal/schedule/#exegol-professional-hacking-setup-30815" title="Schedule"> 25 | <img alt="Black Hat Asia 2023" src="https://img.shields.io/badge/Black%20Hat%20Arsenal-Asia%202023-blueviolet"> 26 | </a> 27 | <a target="_blank" rel="noopener noreferrer" href="https://www.blackhat.com/us-23/arsenal/schedule/#exegol-professional-hacking-setup-31711" title="Schedule"> 28 | <img alt="Black Hat USA 2023" src="https://img.shields.io/badge/Black%20Hat%20Arsenal-USA%202023-blueviolet"> 29 | </a> 30 | <br><br> 31 | <a target="_blank" rel="noopener noreferrer" href="https://discord.gg/cXThyp7D6P" title="Join us on Discord"><img src="https://raw.githubusercontent.com/ThePorgs/Exegol-docs/main/.assets/discord_join_us.png" width="150" alt="Join us on Discord"></a> 32 | <br><br> 33 | </div> 34 | 35 | > Exegol is a community-driven hacking environment, powerful and yet simple enough to be used by anyone in day to day engagements. Exegol is the best solution to deploy powerful hacking environments securely, easily, professionally. 36 | > Exegol fits pentesters, CTF players, bug bounty hunters, researchers, beginners and advanced users, defenders, from stylish macOS users and corporate Windows pros to UNIX-like power users. 37 | 38 | # Getting started 39 | 40 | You can refer to the [Exegol documentation](https://exegol.readthedocs.io/en/latest/getting-started/install.html). 41 | 42 | > Full documentation homepage: https://exegol.rtfd.io/. 43 | 44 | ## Project structure 45 | 46 | Below are some bullet points to better understand how Exegol works 47 | - This repository ([Exegol](https://github.com/ThePorgs/Exegol)) contains the code for the Python wrapper. It's the entrypoint of the Exegol project. The wrapper can be installed from sources, but [a PyPI package](https://pypi.org/project/Exegol/) is available. 48 | - The [Exegol-images](https://github.com/ThePorgs/Exegol-images) repo is loaded as a submodule. It includes all necessary assets to build Docker images. Notabene: the image are already built and offered on [the official Dockerhub registry](https://hub.docker.com/repository/docker/nwodtuhs/exegol). 49 | - The [Exegol-resources](https://github.com/ThePorgs/Exegol-resources) repo is loaded as a submodule. It includes all resources mentioned previously (LinPEAS, WinPEAS, LinEnum, PrivescCheck, SysinternalsSuite, mimikatz, Rubeus, PowerSploit and many more.). 50 | - The [Exegol-docs](https://github.com/ThePorgs/Exegol-docs) repo for the documentation, destined for users as well as developpers and contributors. The GitHub repo holds the sources that are compiled on https://exegol.readthedocs.io/. 51 | -------------------------------------------------------------------------------- /exegol.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # PYTHON_ARGCOMPLETE_OK 4 | from exegol.manager.ExegolController import main 5 | 6 | if __name__ == '__main__': 7 | main() 8 | -------------------------------------------------------------------------------- /exegol/__init__.py: -------------------------------------------------------------------------------- 1 | from exegol.config.ConstantConfig import __version__ 2 | 3 | __title__ = "exegol" 4 | -------------------------------------------------------------------------------- /exegol/__main__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from exegol.manager.ExegolController import main 3 | except ModuleNotFoundError as e: 4 | print("Mandatory dependencies are missing:", e) 5 | print("Please install them with pip3 install -r requirements.txt") 6 | exit(1) 7 | 8 | if __name__ == "__main__": 9 | main() 10 | -------------------------------------------------------------------------------- /exegol/config/ConstantConfig.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | __version__ = "4.3.11" 4 | 5 | 6 | class ConstantConfig: 7 | """Constant parameters information""" 8 | # Exegol Version 9 | version: str = __version__ 10 | 11 | # Exegol documentation link 12 | documentation: str = "https://exegol.rtfd.io/" 13 | discord: str = "https://discord.gg/cXThyp7D6P" 14 | # OS Dir full root path of exegol project 15 | src_root_path_obj: Path = Path(__file__).parent.parent.parent.resolve() 16 | # Path of the entrypoint.sh 17 | entrypoint_context_path_obj: Path = src_root_path_obj / "exegol/utils/imgsync/entrypoint.sh" 18 | # Path of the spawn.sh 19 | spawn_context_path_obj: Path = src_root_path_obj / "exegol/utils/imgsync/spawn.sh" 20 | # Exegol config directory 21 | exegol_config_path: Path = Path().home() / ".exegol" 22 | # Docker Desktop for mac config file 23 | docker_desktop_mac_config_path = Path().home() / "Library/Group Containers/group.com.docker" 24 | docker_desktop_windows_config_short_path = "AppData/Roaming/Docker" 25 | docker_desktop_windows_config_path = Path().home() / docker_desktop_windows_config_short_path 26 | # Install mode, check if Exegol has been git cloned or installed using pip package 27 | git_source_installation: bool = (src_root_path_obj / '.git').is_dir() 28 | pip_installed: bool = src_root_path_obj.name == "site-packages" 29 | pipx_installed: bool = "/pipx/venvs/" in src_root_path_obj.as_posix() 30 | # Dockerhub Exegol images repository 31 | DOCKER_HUB: str = "hub.docker.com" # Don't handle docker login operations 32 | DOCKER_REGISTRY: str = "registry-1.docker.io" # Don't handle docker login operations 33 | IMAGE_NAME: str = "nwodtuhs/exegol" 34 | GITHUB_REPO: str = "ThePorgs/Exegol" 35 | # Docker volume names (no docker volume used at this moment) 36 | # Resources repository 37 | EXEGOL_IMAGES_REPO: str = "https://github.com/ThePorgs/Exegol-images.git" 38 | EXEGOL_RESOURCES_REPO: str = "https://github.com/ThePorgs/Exegol-resources.git" 39 | -------------------------------------------------------------------------------- /exegol/config/DataCache.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from exegol.model.CacheModels import * 4 | from exegol.utils.DataFileUtils import DataFileUtils 5 | from exegol.utils.MetaSingleton import MetaSingleton 6 | 7 | 8 | class DataCache(DataFileUtils, metaclass=MetaSingleton): 9 | """This class allows loading cached information defined configurations 10 | 11 | Example of data: 12 | { 13 | wrapper: { 14 | update: { 15 | metadata: { 16 | last_check DATE 17 | } 18 | last_version: STR 19 | } 20 | } 21 | images: 22 | metadata: { 23 | last_check: DATE 24 | } 25 | data: [ 26 | { 27 | name: STR (tag name) 28 | last_version: STR (x.y.z|commit_id) 29 | type: STR (local|remote) 30 | } 31 | ] 32 | } 33 | """ 34 | 35 | def __init__(self) -> None: 36 | # Cache data 37 | self.__cache_data = CacheDB() 38 | 39 | # Config file options 40 | super().__init__(".datacache", "json") 41 | 42 | def _process_data(self) -> None: 43 | if len(self._raw_data) >= 2: 44 | self.__cache_data.load(**self._raw_data) 45 | 46 | def _build_file_content(self) -> str: 47 | return json.dumps(self.__cache_data, cls=self.ObjectJSONEncoder) 48 | 49 | def save_updates(self) -> None: 50 | self._create_config_file() 51 | 52 | def get_wrapper_data(self) -> WrapperCacheModel: 53 | """Get Wrapper information from cache""" 54 | return self.__cache_data.wrapper 55 | 56 | def get_images_data(self) -> ImagesCacheModel: 57 | """Get Images information from cache""" 58 | return self.__cache_data.images 59 | 60 | def update_image_cache(self, images: List) -> None: 61 | """Refresh image cache data""" 62 | logger.debug("Updating image cache data") 63 | cache_images = [] 64 | for img in images: 65 | name = img.getName() 66 | version = img.getLatestVersion() 67 | if "N/A" in version: 68 | continue 69 | remote_id = img.getLatestRemoteId() 70 | image_type = "local" if img.isLocal() else "remote" 71 | logger.debug(f"└── {name} (version: {version})\t→ ({image_type}) {remote_id}") 72 | cache_images.append( 73 | ImageCacheModel( 74 | name, 75 | version, 76 | remote_id, 77 | image_type 78 | ) 79 | ) 80 | self.__cache_data.images = ImagesCacheModel(cache_images) 81 | self.save_updates() 82 | -------------------------------------------------------------------------------- /exegol/config/EnvInfo.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import platform 4 | from enum import Enum 5 | from json import JSONDecodeError 6 | from pathlib import Path 7 | from typing import Optional, List, Dict 8 | 9 | from exegol.config.ConstantConfig import ConstantConfig 10 | from exegol.utils.ExeLog import logger 11 | 12 | 13 | class EnvInfo: 14 | """Class to identify the environment in which exegol runs to adapt 15 | the configurations, processes and messages for the user""" 16 | 17 | class HostOs(Enum): 18 | """Dictionary class for static OS Name""" 19 | WINDOWS = "Windows" 20 | LINUX = "Linux" 21 | MAC = "Mac" 22 | 23 | class DisplayServer(Enum): 24 | """Dictionary class for static Display Server""" 25 | WAYLAND = "Wayland" 26 | X11 = "X11" 27 | 28 | class DockerEngine(Enum): 29 | """Dictionary class for static Docker engine name""" 30 | WSL2 = "WSL2" 31 | HYPERV = "Hyper-V" 32 | DOCKER_DESKTOP = "Docker desktop" 33 | ORBSTACK = "Orbstack" 34 | LINUX = "Kernel" 35 | 36 | """Contain information about the environment (host, OS, platform, etc)""" 37 | # Shell env 38 | current_platform: str = "WSL" if "microsoft" in platform.release() else platform.system() # Can be 'Windows', 'Linux' or 'WSL' 39 | is_linux_shell: bool = current_platform in ["WSL", "Linux"] 40 | is_windows_shell: bool = current_platform == "Windows" 41 | is_mac_shell: bool = not is_windows_shell and not is_linux_shell # If not Linux nor Windows, its (probably) a mac 42 | __is_docker_desktop: bool = False 43 | __windows_release: Optional[str] = None 44 | # Host OS 45 | __docker_host_os: Optional[HostOs] = None 46 | __docker_engine: Optional[DockerEngine] = None 47 | # Docker desktop cache config 48 | __docker_desktop_resource_config: Optional[dict] = None 49 | # Architecture 50 | raw_arch = platform.machine().lower() 51 | arch = raw_arch 52 | if arch == "x86_64" or arch == "x86-64" or arch == "amd64": 53 | arch = "amd64" 54 | elif arch == "aarch64" or "armv8" in arch: 55 | arch = "arm64" 56 | elif "arm" in arch: 57 | if platform.architecture()[0] == '64bit': 58 | arch = "arm64" 59 | else: 60 | logger.error(f"Host architecture seems to be 32-bit ARM ({arch}), which is not supported yet. " 61 | f"If possible, please install a 64-bit operating system (Exegol supports ARM64).") 62 | """ 63 | if "v5" in arch: 64 | arch = "arm/v5" 65 | elif "v6" in arch: 66 | arch = "arm/v6" 67 | elif "v7" in arch: 68 | arch = "arm/v7" 69 | elif "v8" in arch: 70 | arch = "arm64" 71 | """ 72 | else: 73 | logger.warning(f"Unknown / unsupported architecture: {arch}. Using 'AMD64' as default.") 74 | # Fallback to default AMD64 arch 75 | arch = "amd64" 76 | 77 | @classmethod 78 | def initData(cls, docker_info) -> None: 79 | """Initialize information from Docker daemon data""" 80 | # Fetch data from Docker daemon 81 | docker_os = docker_info.get("OperatingSystem", "unknown").lower() 82 | docker_kernel = docker_info.get("KernelVersion", "unknown").lower() 83 | # Deduct a Windows Host from data 84 | cls.__is_docker_desktop = docker_os == "docker desktop" 85 | is_host_windows = cls.__is_docker_desktop and "microsoft" in docker_kernel 86 | is_orbstack = (docker_os == "orbstack" or "(containerized)" in docker_os) and "orbstack" in docker_kernel 87 | if is_host_windows: 88 | # Check docker engine with Windows host 89 | if "wsl2" in docker_kernel: 90 | cls.__docker_engine = cls.DockerEngine.WSL2 91 | else: 92 | cls.__docker_engine = cls.DockerEngine.HYPERV 93 | cls.__docker_host_os = cls.HostOs.WINDOWS 94 | elif cls.__is_docker_desktop: 95 | # If docker desktop is detected but not a Windows engine/kernel, it's (probably) a mac 96 | cls.__docker_engine = cls.DockerEngine.DOCKER_DESKTOP 97 | cls.__docker_host_os = cls.HostOs.MAC if cls.is_mac_shell else cls.HostOs.LINUX 98 | elif is_orbstack: 99 | # Orbstack is only available on Mac 100 | cls.__docker_engine = cls.DockerEngine.ORBSTACK 101 | cls.__docker_host_os = cls.HostOs.MAC 102 | else: 103 | # Every other case it's a linux distro and docker is powered from the kernel 104 | cls.__docker_engine = cls.DockerEngine.LINUX 105 | cls.__docker_host_os = cls.HostOs.LINUX 106 | 107 | if cls.__docker_engine == cls.DockerEngine.DOCKER_DESKTOP and cls.__docker_host_os == cls.HostOs.LINUX: 108 | logger.warning(f"Using Docker Desktop on Linux is not officially supported !") 109 | 110 | @classmethod 111 | def getHostOs(cls) -> HostOs: 112 | """Return Host OS 113 | Can be 'Windows', 'Mac' or 'Linux'""" 114 | # initData must be called from DockerUtils on client initialisation 115 | assert cls.__docker_host_os is not None 116 | return cls.__docker_host_os 117 | 118 | @classmethod 119 | def getDisplayServer(cls) -> DisplayServer: 120 | """Returns the display server 121 | Can be 'X11' or 'Wayland'""" 122 | session_type = os.getenv("XDG_SESSION_TYPE", "x11") 123 | if session_type == "wayland": 124 | return cls.DisplayServer.WAYLAND 125 | elif session_type in ["x11", "tty"]: # When using SSH X11 forwarding, the session type is "tty" instead of the classic "x11" 126 | return cls.DisplayServer.X11 127 | else: 128 | # Should return an error 129 | logger.warning(f"Unknown session type {session_type}. Using X11 as fallback.") 130 | return cls.DisplayServer.X11 131 | 132 | @classmethod 133 | def getWindowsRelease(cls) -> str: 134 | # Cache check 135 | if cls.__windows_release is None: 136 | if cls.is_windows_shell: 137 | # From a Windows shell, python supply an approximate (close enough) version of windows 138 | cls.__windows_release = platform.win32_ver()[1] 139 | else: 140 | cls.__windows_release = "Unknown" 141 | return cls.__windows_release 142 | 143 | @classmethod 144 | def isWindowsHost(cls) -> bool: 145 | """Return true if Windows is detected on the host""" 146 | return cls.getHostOs() == cls.HostOs.WINDOWS 147 | 148 | @classmethod 149 | def isMacHost(cls) -> bool: 150 | """Return true if macOS is detected on the host""" 151 | return cls.getHostOs() == cls.HostOs.MAC 152 | 153 | @classmethod 154 | def isLinuxHost(cls) -> bool: 155 | """Return true if Linux is detected on the host""" 156 | return cls.getHostOs() == cls.HostOs.LINUX 157 | 158 | @classmethod 159 | def isWaylandAvailable(cls) -> bool: 160 | """Return true if wayland is detected on the host""" 161 | return cls.getDisplayServer() == cls.DisplayServer.WAYLAND or bool(os.getenv("WAYLAND_DISPLAY")) 162 | 163 | @classmethod 164 | def isDockerDesktop(cls) -> bool: 165 | """Return true if docker desktop is used on the host""" 166 | return cls.__is_docker_desktop 167 | 168 | @classmethod 169 | def isOrbstack(cls) -> bool: 170 | """Return true if docker desktop is used on the host""" 171 | return cls.__docker_engine == cls.DockerEngine.ORBSTACK 172 | 173 | @classmethod 174 | def getDockerEngine(cls) -> DockerEngine: 175 | """Return Docker engine type. 176 | Can be any of EnvInfo.DockerEngine""" 177 | # initData must be called from DockerUtils on client initialisation 178 | assert cls.__docker_engine is not None 179 | return cls.__docker_engine 180 | 181 | @classmethod 182 | def getShellType(cls) -> str: 183 | """Return the type of shell exegol is executed from""" 184 | if cls.is_linux_shell: 185 | return cls.HostOs.LINUX.value 186 | elif cls.is_windows_shell: 187 | return cls.HostOs.WINDOWS.value 188 | elif cls.is_mac_shell: 189 | return cls.HostOs.MAC.value 190 | else: 191 | return "Unknown" 192 | 193 | @classmethod 194 | def getDockerDesktopSettings(cls) -> Dict: 195 | """Applicable only for docker desktop on macos""" 196 | if cls.isDockerDesktop(): 197 | if cls.__docker_desktop_resource_config is None: 198 | dir_path = None 199 | file_path = None 200 | if cls.is_mac_shell: 201 | # Mac PATH 202 | dir_path = ConstantConfig.docker_desktop_mac_config_path 203 | elif cls.is_windows_shell: 204 | # Windows PATH 205 | dir_path = ConstantConfig.docker_desktop_windows_config_path 206 | else: 207 | # Windows PATH from WSL shell 208 | # Find docker desktop config 209 | config_file = list(Path("/mnt/c/Users").glob(f"*/{ConstantConfig.docker_desktop_windows_config_short_path}/settings-store.json")) 210 | if len(config_file) == 0: 211 | # Testing with legacy file name 212 | config_file = list(Path("/mnt/c/Users").glob(f"*/{ConstantConfig.docker_desktop_windows_config_short_path}/settings.json")) 213 | if len(config_file) == 0: 214 | logger.warning(f"No docker desktop settings file found.") 215 | return {} 216 | file_path = config_file[0] 217 | if file_path is None: 218 | assert dir_path is not None 219 | # Try to find settings file with new filename or fallback to legacy filename for Docker Desktop older than 4.34 220 | file_path = (dir_path / "settings-store.json") if (dir_path / "settings-store.json").is_file() else (dir_path / "settings.json") 221 | logger.debug(f"Loading Docker Desktop config from {file_path}") 222 | try: 223 | with open(file_path, 'r') as docker_desktop_config: 224 | cls.__docker_desktop_resource_config = json.load(docker_desktop_config) 225 | except FileNotFoundError: 226 | logger.warning(f"Docker Desktop configuration file not found: '{file_path}'") 227 | return {} 228 | except JSONDecodeError: 229 | logger.critical(f"The Docker Desktop configuration file '{file_path}' is not a valid JSON. Please fix your configuration file first.") 230 | if cls.__docker_desktop_resource_config is None: 231 | logger.warning(f"Docker Desktop configuration couldn't be loaded.'") 232 | else: 233 | return cls.__docker_desktop_resource_config 234 | return {} 235 | 236 | @classmethod 237 | def getDockerDesktopResources(cls) -> List[str]: 238 | settings = cls.getDockerDesktopSettings() 239 | # Handle legacy settings key 240 | docker_desktop_resources = settings.get('FilesharingDirectories', settings.get('filesharingDirectories', [])) 241 | logger.debug(f"Docker Desktop resources whitelist: {docker_desktop_resources}") 242 | return docker_desktop_resources 243 | 244 | @classmethod 245 | def isHostNetworkAvailable(cls) -> bool: 246 | if cls.isLinuxHost(): 247 | return True 248 | elif cls.isOrbstack(): 249 | return True 250 | elif cls.isDockerDesktop(): 251 | settings = cls.getDockerDesktopSettings() 252 | # Handle legacy settings key 253 | res = settings.get('HostNetworkingEnabled', settings.get('hostNetworkingEnabled', False)) 254 | return res if res is not None else False 255 | logger.warning("Unknown or not supported environment for host network mode.") 256 | return False 257 | -------------------------------------------------------------------------------- /exegol/config/UserConfig.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List 3 | 4 | from exegol.config.ConstantConfig import ConstantConfig 5 | from exegol.console.ConsoleFormat import boolFormatter 6 | from exegol.utils.DataFileUtils import DataFileUtils 7 | from exegol.utils.MetaSingleton import MetaSingleton 8 | 9 | 10 | class UserConfig(DataFileUtils, metaclass=MetaSingleton): 11 | """This class allows loading user defined configurations""" 12 | 13 | # Static choices 14 | start_shell_options = {'zsh', 'bash', 'tmux'} 15 | shell_logging_method_options = {'script', 'asciinema'} 16 | desktop_available_proto = {'http', 'vnc'} 17 | 18 | def __init__(self) -> None: 19 | # Defaults User config 20 | self.private_volume_path: Path = ConstantConfig.exegol_config_path / "workspaces" 21 | self.my_resources_path: Path = ConstantConfig.exegol_config_path / "my-resources" 22 | self.exegol_resources_path: Path = self.__default_resource_location('exegol-resources') 23 | self.exegol_images_path: Path = self.__default_resource_location('exegol-images') 24 | self.auto_check_updates: bool = True 25 | self.auto_remove_images: bool = True 26 | self.auto_update_workspace_fs: bool = False 27 | self.default_start_shell: str = "zsh" 28 | self.enable_exegol_resources: bool = True 29 | self.shell_logging_method: str = "asciinema" 30 | self.shell_logging_compress: bool = True 31 | self.desktop_default_enable: bool = False 32 | self.desktop_default_localhost: bool = True 33 | self.desktop_default_proto: str = "http" 34 | 35 | super().__init__("config.yml", "yml") 36 | 37 | def _build_file_content(self) -> str: 38 | config = f"""# Exegol configuration 39 | # Full documentation: https://exegol.readthedocs.io/en/latest/exegol-wrapper/advanced-uses.html#id1 40 | 41 | # Volume path can be changed at any time but existing containers will not be affected by the update 42 | volumes: 43 | # The my-resources volume is a storage space dedicated to the user to customize his environment and tools. This volume can be shared across all exegol containers. 44 | # Attention! The permissions of this folder (and subfolders) will be updated to share read/write rights between the host (user) and the container (root). Do not modify this path to a folder on which the permissions (chmod) should not be modified. 45 | my_resources_path: {self.my_resources_path} 46 | 47 | # Exegol resources are data and static tools downloaded in addition to docker images. These tools are complementary and are accessible directly from the host. 48 | exegol_resources_path: {self.exegol_resources_path} 49 | 50 | # Exegol images are the source of the exegol environments. These sources are needed when locally building an exegol image. 51 | exegol_images_path: {self.exegol_images_path} 52 | 53 | # When containers do not have an explicitly declared workspace, a dedicated folder will be created at this location to share the workspace with the host but also to save the data after deleting the container 54 | private_workspace_path: {self.private_volume_path} 55 | 56 | config: 57 | # Enables automatic check for wrapper updates 58 | auto_check_update: {self.auto_check_updates} 59 | 60 | # Automatically remove outdated image when they are no longer used 61 | auto_remove_image: {self.auto_remove_images} 62 | 63 | # Automatically modifies the permissions of folders and sub-folders in your workspace by default to enable file sharing between the container with your host user. 64 | auto_update_workspace_fs: {self.auto_update_workspace_fs} 65 | 66 | # Default shell command to start 67 | default_start_shell: {self.default_start_shell} 68 | 69 | # Enable Exegol resources 70 | enable_exegol_resources: {self.enable_exegol_resources} 71 | 72 | # Change the configuration of the shell logging functionality 73 | shell_logging: 74 | #Choice of the method used to record the sessions (script or asciinema) 75 | logging_method: {self.shell_logging_method} 76 | 77 | # Enable automatic compression of log files (with gzip) 78 | enable_log_compression: {self.shell_logging_compress} 79 | 80 | # Configure your Exegol Desktop 81 | desktop: 82 | # Enables or not the desktop mode by default 83 | # If this attribute is set to True, then using the CLI --desktop option will be inverted and will DISABLE the feature 84 | enabled_by_default: {self.desktop_default_enable} 85 | 86 | # Default desktop protocol,can be "http", or "vnc" (additional protocols to come in the future, check online documentation for updates). 87 | default_protocol: {self.desktop_default_proto} 88 | 89 | # Desktop service is exposed on localhost by default. If set to true, services will be exposed on localhost (127.0.0.1) otherwise it will be exposed on 0.0.0.0. This setting can be overwritten with --desktop-config 90 | localhost_by_default: {self.desktop_default_localhost} 91 | 92 | """ 93 | return config 94 | 95 | @staticmethod 96 | def __default_resource_location(folder_name: str) -> Path: 97 | local_src = ConstantConfig.src_root_path_obj / folder_name 98 | if local_src.is_dir(): 99 | # If exegol is clone from github, exegol submodule is accessible from root src 100 | return local_src 101 | else: 102 | # Default path for pip installation 103 | return ConstantConfig.exegol_config_path / folder_name 104 | 105 | def _process_data(self) -> None: 106 | # Volume section 107 | volumes_data = self._raw_data.get("volumes", {}) 108 | # Catch existing but empty section 109 | if volumes_data is None: 110 | volumes_data = {} 111 | self.my_resources_path = self._load_config_path(volumes_data, 'my_resources_path', self.my_resources_path) 112 | self.private_volume_path = self._load_config_path(volumes_data, 'private_workspace_path', self.private_volume_path) 113 | self.exegol_resources_path = self._load_config_path(volumes_data, 'exegol_resources_path', self.exegol_resources_path) 114 | self.exegol_images_path = self._load_config_path(volumes_data, 'exegol_images_path', self.exegol_images_path) 115 | 116 | # Config section 117 | config_data = self._raw_data.get("config", {}) 118 | # Catch existing but empty section 119 | if config_data is None: 120 | config_data = {} 121 | self.auto_check_updates = self._load_config_bool(config_data, 'auto_check_update', self.auto_check_updates) 122 | self.auto_remove_images = self._load_config_bool(config_data, 'auto_remove_image', self.auto_remove_images) 123 | self.auto_update_workspace_fs = self._load_config_bool(config_data, 'auto_update_workspace_fs', self.auto_update_workspace_fs) 124 | self.default_start_shell = self._load_config_str(config_data, 'default_start_shell', self.default_start_shell, choices=self.start_shell_options) 125 | self.enable_exegol_resources = self._load_config_bool(config_data, 'enable_exegol_resources', self.enable_exegol_resources) 126 | 127 | # Shell_logging section 128 | shell_logging_data = config_data.get("shell_logging", {}) 129 | self.shell_logging_method = self._load_config_str(shell_logging_data, 'logging_method', self.shell_logging_method, choices=self.shell_logging_method_options) 130 | self.shell_logging_compress = self._load_config_bool(shell_logging_data, 'enable_log_compression', self.shell_logging_compress) 131 | 132 | # Desktop section 133 | desktop_data = config_data.get("desktop", {}) 134 | self.desktop_default_enable = self._load_config_bool(desktop_data, 'enabled_by_default', self.desktop_default_enable) 135 | self.desktop_default_proto = self._load_config_str(desktop_data, 'default_protocol', self.desktop_default_proto, choices=self.desktop_available_proto) 136 | self.desktop_default_localhost = self._load_config_bool(desktop_data, 'localhost_by_default', self.desktop_default_localhost) 137 | 138 | def get_configs(self) -> List[str]: 139 | """User configs getter each options""" 140 | configs = [ 141 | f"User config file: [magenta]{self._file_path}[/magenta]", 142 | f"Private workspace: [magenta]{self.private_volume_path}[/magenta]", 143 | "Exegol resources: " + (f"[magenta]{self.exegol_resources_path}[/magenta]" 144 | if self.enable_exegol_resources else 145 | boolFormatter(self.enable_exegol_resources)), 146 | f"Exegol images: [magenta]{self.exegol_images_path}[/magenta]", 147 | f"My resources: [magenta]{self.my_resources_path}[/magenta]", 148 | f"Auto-check updates: {boolFormatter(self.auto_check_updates)}", 149 | f"Auto-remove images: {boolFormatter(self.auto_remove_images)}", 150 | f"Auto-update fs: {boolFormatter(self.auto_update_workspace_fs)}", 151 | f"Default start shell: [blue]{self.default_start_shell}[/blue]", 152 | f"Shell logging method: [blue]{self.shell_logging_method}[/blue]", 153 | f"Shell logging compression: {boolFormatter(self.shell_logging_compress)}", 154 | f"Desktop enabled by default: {boolFormatter(self.desktop_default_enable)}", 155 | f"Desktop default protocol: [blue]{self.desktop_default_proto}[/blue]", 156 | f"Desktop default host: [blue]{'localhost' if self.desktop_default_localhost else '0.0.0.0'}[/blue]", 157 | ] 158 | # TUI can't be called from here to avoid circular importation 159 | return configs 160 | -------------------------------------------------------------------------------- /exegol/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePorgs/Exegol/3ef46e7fff8b45617dead7c6d5ea83635d609834/exegol/config/__init__.py -------------------------------------------------------------------------------- /exegol/console/ConsoleFormat.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Tuple, Union 3 | 4 | 5 | # Generic text generation functions 6 | 7 | def boolFormatter(val: bool) -> str: 8 | """Generic text formatter for bool value""" 9 | return '[green]On :heavy_check_mark:[/green] ' if val else '[orange3]Off :axe:[/orange3]' 10 | 11 | 12 | def getColor(val: Union[bool, int, str]) -> Tuple[str, str]: 13 | """Generic text color getter for bool value""" 14 | if type(val) is str: 15 | try: 16 | val = int(val) 17 | except ValueError: 18 | val = False 19 | return ('[green]', '[/green]') if val else ('[orange3]', '[/orange3]') 20 | 21 | 22 | def richLen(text: str) -> int: 23 | """Get real length of a text without Rich colors""" 24 | # remove rich color tags 25 | color_removed = re.sub(r"\[/?[^]]+]", '', text, 0, re.MULTILINE) 26 | # replace emoji by two random char (because emoji are wide) 27 | emoji_removed = re.sub(r":[a-z-_+()\d'’.&]+:", 'XX', color_removed, 0, re.MULTILINE) 28 | return len(emoji_removed) 29 | 30 | 31 | def getArchColor(arch: str) -> str: 32 | if arch.startswith("arm"): 33 | color = "slate_blue3" 34 | elif "amd64" == arch: 35 | color = "medium_orchid3" 36 | else: 37 | color = "yellow3" 38 | return color 39 | -------------------------------------------------------------------------------- /exegol/console/ExegolProgress.py: -------------------------------------------------------------------------------- 1 | from typing import cast, Union, Optional 2 | 3 | from rich.console import Console 4 | from rich.progress import Progress, Task, TaskID, ProgressColumn, GetTimeCallable 5 | 6 | from exegol.utils.ExeLog import console as exelog_console 7 | 8 | 9 | class ExegolProgress(Progress): 10 | """Addition of a practical function to Rich Progress""" 11 | 12 | def __init__(self, *columns: Union[str, ProgressColumn], console: Optional[Console] = None, auto_refresh: bool = True, refresh_per_second: float = 10, speed_estimate_period: float = 30.0, 13 | transient: bool = False, redirect_stdout: bool = True, redirect_stderr: bool = True, get_time: Optional[GetTimeCallable] = None, disable: bool = False, expand: bool = False) -> None: 14 | if console is None: 15 | console = exelog_console 16 | super().__init__(*columns, console=console, auto_refresh=auto_refresh, refresh_per_second=refresh_per_second, speed_estimate_period=speed_estimate_period, transient=transient, 17 | redirect_stdout=redirect_stdout, redirect_stderr=redirect_stderr, get_time=get_time, disable=disable, expand=expand) 18 | 19 | def getTask(self, task_id: TaskID) -> Task: 20 | """Return a specific task from task_id without error""" 21 | task = self._tasks.get(task_id) 22 | if task is None: 23 | # If task doesn't exist, raise IndexError exception 24 | raise IndexError 25 | return cast(Task, task) 26 | 27 | def __enter__(self) -> "ExegolProgress": 28 | super(ExegolProgress, self).__enter__() 29 | return self 30 | -------------------------------------------------------------------------------- /exegol/console/ExegolPrompt.py: -------------------------------------------------------------------------------- 1 | import rich.prompt 2 | 3 | 4 | def Confirm(question: str, default: bool) -> bool: 5 | """Quick function to format rich Confirmation and options on every exegol interaction""" 6 | default_text = "[bright_magenta][Y/n][/bright_magenta]" if default else "[bright_magenta]\\[y/N][/bright_magenta]" 7 | formatted_question = f"[bold blue][?][/bold blue] {question} {default_text}" 8 | return rich.prompt.Confirm.ask( 9 | formatted_question, 10 | show_choices=False, 11 | show_default=False, 12 | default=default) 13 | -------------------------------------------------------------------------------- /exegol/console/LayerTextColumn.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | 4 | from rich.console import JustifyMethod 5 | from rich.highlighter import Highlighter 6 | from rich.progress import TextColumn, Task, DownloadColumn 7 | from rich.style import StyleType 8 | from rich.table import Column 9 | from rich.text import Text 10 | 11 | from exegol.utils.ExeLog import logger 12 | 13 | 14 | class LayerTextColumn(TextColumn, DownloadColumn): 15 | """Merging two Rich class to obtain a double behavior in the same RichTable""" 16 | 17 | def __init__(self, 18 | text_format: str, 19 | layer_key: str, 20 | style: StyleType = "none", 21 | justify: JustifyMethod = "left", 22 | markup: bool = True, 23 | highlighter: Optional[Highlighter] = None, 24 | table_column: Optional[Column] = None, 25 | binary_units: bool = False 26 | ) -> None: 27 | # Custom field 28 | self.__data_key = layer_key 29 | # Inheritance configuration 30 | try: 31 | TextColumn.__init__(self, text_format, style, justify, markup, highlighter, table_column) 32 | except TypeError: 33 | logger.critical(f"Your version of Rich does not correspond to the project requirements. Please update your dependencies with pip:{os.linesep}" 34 | f"[bright_magenta]python3 -m pip install --user --requirement requirements.txt[/bright_magenta]") 35 | DownloadColumn.__init__(self, binary_units, table_column) 36 | 37 | def render(self, task: "Task") -> Text: 38 | """Custom render depending on the existence of data with data_key""" 39 | if task.fields.get(self.__data_key) is None: 40 | # Default render with classic Text render 41 | return TextColumn.render(self, task) 42 | else: 43 | # If the task download a file, render the Download progress view 44 | return DownloadColumn.render(self, task) 45 | -------------------------------------------------------------------------------- /exegol/console/MetaGitProgress.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Optional, Dict 2 | 3 | from git import RemoteProgress 4 | from git.objects.submodule.base import UpdateProgress 5 | from rich.console import Console 6 | from rich.progress import Progress, ProgressColumn, GetTimeCallable, Task 7 | 8 | from exegol.utils.ExeLog import console as exelog_console 9 | from exegol.utils.ExeLog import logger 10 | from exegol.utils.MetaSingleton import MetaSingleton 11 | 12 | 13 | class SubmoduleUpdateProgress(UpdateProgress): 14 | 15 | def update(self, op_code: int, cur_count: Union[str, float], max_count: Union[str, float, None] = None, message: str = "") -> None: 16 | # Full debug log 17 | logger.debug(f"[{op_code}] {cur_count}/{max_count} : '{message}'") 18 | if message: 19 | logger.verbose(f"{message}") 20 | if max_count is None: 21 | max_count = 0 22 | max_count = int(max_count) 23 | cur_count = int(cur_count) 24 | main_task = MetaGitProgress().tasks[0] 25 | step = 0 26 | 27 | # CLONING 28 | if MetaGitProgress.handle_task(op_code, self.CLONE, "Cloning", max_count, cur_count): 29 | step = 1 30 | 31 | # UPDWKTREE 32 | if MetaGitProgress.handle_task(op_code, self.UPDWKTREE, "Updating local git registry", max_count, cur_count): 33 | step = 2 34 | 35 | main_task.total = 2 36 | main_task.completed = step 37 | 38 | 39 | def clone_update_progress(op_code: int, cur_count: Union[str, float], max_count: Union[str, float, None] = None, message: str = '') -> None: 40 | # Full debug log 41 | # logger.debug(f"[{op_code}] {cur_count}/{max_count} : '{message}'") 42 | if max_count is None: 43 | max_count = 0 44 | max_count = int(max_count) 45 | cur_count = int(cur_count) 46 | main_task = MetaGitProgress().tasks[0] 47 | step = 0 48 | 49 | # COUNTING 50 | if MetaGitProgress.handle_task(op_code, RemoteProgress.COUNTING, "Counting git objects", max_count, cur_count, message): 51 | step = 1 52 | 53 | # COMPRESSING 54 | elif MetaGitProgress.handle_task(op_code, RemoteProgress.COMPRESSING, "Compressing", max_count, cur_count, message): 55 | step = 2 56 | 57 | # RECEIVING 58 | elif MetaGitProgress.handle_task(op_code, RemoteProgress.RECEIVING, "Downloading", max_count, cur_count, message): 59 | step = 3 60 | 61 | # RESOLVING 62 | elif MetaGitProgress.handle_task(op_code, RemoteProgress.RESOLVING, "Resolving", max_count, cur_count, message): 63 | step = 4 64 | else: 65 | logger.debug(f"Git OPCODE {op_code} is not handled by Exegol TUI.") 66 | 67 | main_task.total = 4 68 | main_task.completed = step 69 | 70 | 71 | class MetaGitProgress(Progress, metaclass=MetaSingleton): 72 | """Singleton instance of the current Progress. Used with git operation to support callback updates.""" 73 | 74 | def __init__(self, *columns: Union[str, ProgressColumn], console: Optional[Console] = None, auto_refresh: bool = True, refresh_per_second: float = 10, speed_estimate_period: float = 30.0, 75 | transient: bool = False, redirect_stdout: bool = True, redirect_stderr: bool = True, get_time: Optional[GetTimeCallable] = None, disable: bool = False, expand: bool = False) -> None: 76 | if console is None: 77 | console = exelog_console 78 | self.task_dict: Dict[int, Task] = {} 79 | 80 | super().__init__(*columns, console=console, auto_refresh=auto_refresh, refresh_per_second=refresh_per_second, speed_estimate_period=speed_estimate_period, transient=transient, 81 | redirect_stdout=redirect_stdout, redirect_stderr=redirect_stderr, get_time=get_time, disable=disable, expand=expand) 82 | 83 | @staticmethod 84 | def handle_task(op_code: int, ref_op_code: int, description: str, total: int, completed: int, message: str = '') -> bool: 85 | description = "[bold gold1]" + description 86 | # filter op code 87 | if op_code & ref_op_code != 0: 88 | # new task to create 89 | if op_code & RemoteProgress.BEGIN != 0: 90 | MetaGitProgress().add_task(description, start=True, total=total, completed=completed) 91 | MetaGitProgress().task_dict[ref_op_code] = MetaGitProgress().tasks[-1] 92 | else: 93 | counting_task = MetaGitProgress().task_dict.get(ref_op_code) 94 | if counting_task is not None: 95 | counting_task.completed = completed 96 | if message: 97 | description += f" • [green4]{message}" 98 | counting_task.description = description 99 | if op_code & RemoteProgress.END != 0: 100 | MetaGitProgress().remove_task(MetaGitProgress().task_dict[ref_op_code].id) 101 | return True 102 | return False 103 | -------------------------------------------------------------------------------- /exegol/console/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePorgs/Exegol/3ef46e7fff8b45617dead7c6d5ea83635d609834/exegol/console/__init__.py -------------------------------------------------------------------------------- /exegol/console/cli/ExegolCompleter.py: -------------------------------------------------------------------------------- 1 | from argparse import Namespace 2 | from pathlib import Path 3 | from typing import Tuple 4 | 5 | from exegol.config.ConstantConfig import ConstantConfig 6 | from exegol.config.DataCache import DataCache 7 | from exegol.config.UserConfig import UserConfig 8 | from exegol.manager.UpdateManager import UpdateManager 9 | from exegol.utils.DockerUtils import DockerUtils 10 | 11 | 12 | def ContainerCompleter(prefix: str, parsed_args: Namespace, **kwargs) -> Tuple[str, ...]: 13 | """Function to dynamically load a container list for CLI autocompletion purpose""" 14 | data = [c.name for c in DockerUtils().listContainers()] 15 | for obj in data: 16 | # filter data if needed 17 | if prefix and not obj.lower().startswith(prefix.lower()): 18 | data.remove(obj) 19 | return tuple(data) 20 | 21 | 22 | def ImageCompleter(prefix: str, parsed_args: Namespace, **kwargs) -> Tuple[str, ...]: 23 | """Function to dynamically load an image list for CLI autocompletion purpose""" 24 | # Skip image completer when container hasn't been selected first (because parameters are all optional, parameters order is not working) 25 | if parsed_args is not None and str(parsed_args.action) == "start" and parsed_args.containertag is None: 26 | return () 27 | try: 28 | if parsed_args is not None and str(parsed_args.action) == "install": 29 | data = [img_cache.name for img_cache in DataCache().get_images_data().data if img_cache.source == "remote"] 30 | else: 31 | data = [img_cache.name for img_cache in DataCache().get_images_data().data] 32 | except Exception as e: 33 | data = [] 34 | if len(data) == 0: 35 | # Fallback with default data if the cache is not initialized yet 36 | data = ["full", "nightly", "ad", "web", "light", "osint"] 37 | for obj in data: 38 | # filter data if needed 39 | if prefix and not obj.lower().startswith(prefix.lower()): 40 | data.remove(obj) 41 | return tuple(data) 42 | 43 | 44 | def HybridContainerImageCompleter(prefix: str, parsed_args: Namespace, **kwargs) -> Tuple[str, ...]: 45 | """Hybrid completer for auto-complet. The selector on exec action is hybrid between image and container depending on the mode (tmp or not). 46 | This completer will supply the adequate data.""" 47 | # "exec" parameter is filled first before the selector argument 48 | # If "selector" is null but the selector parameter is set in the first exec slot, no longer need to supply completer options 49 | if parsed_args.selector is None and parsed_args.exec is not None and len(parsed_args.exec) > 0: 50 | return () 51 | # In "tmp" mode, the user must choose an image, otherwise it's a container 52 | if parsed_args.tmp: 53 | return ImageCompleter(prefix, parsed_args, **kwargs) 54 | else: 55 | return ContainerCompleter(prefix, parsed_args, **kwargs) 56 | 57 | 58 | def BuildProfileCompleter(prefix: str, parsed_args: Namespace, **kwargs) -> Tuple[str, ...]: 59 | """Completer function for build profile parameter. The completer must be trigger only when an image name have already been chosen.""" 60 | # The build profile completer must be trigger only when an image name have been set by user 61 | if parsed_args is not None and parsed_args.imagetag is None: 62 | return () 63 | 64 | # Handle custom build path 65 | if parsed_args is not None and parsed_args.build_path is not None: 66 | custom_build_path = Path(parsed_args.build_path).expanduser().absolute() 67 | # Check if we have a directory or a file to select the project directory 68 | if not custom_build_path.is_dir(): 69 | custom_build_path = custom_build_path.parent 70 | build_path = custom_build_path 71 | else: 72 | # Default build path 73 | build_path = Path(UserConfig().exegol_images_path) 74 | 75 | # Check if directory path exist 76 | if not build_path.is_dir(): 77 | return tuple() 78 | 79 | # Find profile list 80 | data = list(UpdateManager.listBuildProfiles(profiles_path=build_path).keys()) 81 | for obj in data: 82 | if prefix and not obj.lower().startswith(prefix.lower()): 83 | data.remove(obj) 84 | return tuple(data) 85 | 86 | 87 | def DesktopConfigCompleter(prefix: str, **kwargs) -> Tuple[str, ...]: 88 | options = list(UserConfig.desktop_available_proto) 89 | for obj in options: 90 | if prefix and not obj.lower().startswith(prefix.lower()): 91 | options.remove(obj) 92 | # TODO add interface enum 93 | return tuple(options) 94 | 95 | 96 | def VoidCompleter(**kwargs) -> Tuple: 97 | """No option to auto-complet""" 98 | return () 99 | -------------------------------------------------------------------------------- /exegol/console/cli/ParametersManager.py: -------------------------------------------------------------------------------- 1 | from argparse import Namespace 2 | from typing import List, Any 3 | 4 | from exegol.console.cli.actions.Command import Command 5 | from exegol.utils.ExeLog import logger 6 | from exegol.utils.MetaSingleton import MetaSingleton 7 | from exegol.utils.argParse import Parser 8 | 9 | 10 | class ParametersManager(metaclass=MetaSingleton): 11 | """This class is a singleton allowing to access from anywhere to any parameter 12 | filled by the user from the CLI arguments""" 13 | 14 | def __init__(self) -> None: 15 | # List every action available on the project (from the root Class) 16 | actions: List[Command] = [cls() for cls in Command.__subclasses__()] 17 | # Load & execute argparse 18 | parser: Parser = Parser(actions) 19 | parsing_results = parser.run_parser() 20 | # The user arguments resulting from the parsing will be stored in parameters 21 | self.parameters: Command = self.__loadResults(parser, parsing_results) 22 | 23 | @staticmethod 24 | def __loadResults(parser: Parser, parsing_results: Namespace) -> Command: 25 | """The result of argparse is sent to the action object to replace the parser with the parsed values""" 26 | try: 27 | action: Command = parsing_results.action 28 | action.populate(parsing_results) 29 | return action 30 | except AttributeError: 31 | # Catch missing "action" parameter en CLI 32 | parser.print_help() 33 | exit(0) 34 | 35 | def getCurrentAction(self) -> Command: 36 | """Return the object corresponding to the action selected by the user""" 37 | return self.parameters 38 | 39 | def __getattr__(self, item: str) -> Any: 40 | """The getattr function is overloaded to transparently pass the parameter search 41 | in the child object of Command stored in the 'parameters' attribute""" 42 | try: 43 | # The priority is to first return the attributes of the current object 44 | # Using the object generic method to avoid infinite loop to itself 45 | return object.__getattribute__(self, item) 46 | except AttributeError: 47 | # If parameters is called before initialisation (from the next statement), this can create an infinite loop 48 | if item == "parameters": 49 | return None 50 | try: 51 | # If item was not found in self, the search is initiated among the parameters 52 | return getattr(self.parameters, item) 53 | except AttributeError: 54 | # The logger may not work if the call is made before its initialization 55 | logger.debug(f"Attribute not found in parameters: {item}") 56 | return None 57 | 58 | def __setattr__(self, key, value) -> None: 59 | """Allow to dynamically change some parameter during runtime""" 60 | # Only some specific parameters are whitelisted for runtime update 61 | if key in ["offline_mode"]: 62 | setattr(self.parameters, key, value) 63 | else: 64 | super().__setattr__(key, value) 65 | -------------------------------------------------------------------------------- /exegol/console/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePorgs/Exegol/3ef46e7fff8b45617dead7c6d5ea83635d609834/exegol/console/cli/__init__.py -------------------------------------------------------------------------------- /exegol/console/cli/actions/Command.py: -------------------------------------------------------------------------------- 1 | import os 2 | from argparse import Namespace 3 | from typing import List, Optional, Tuple, Union, Dict, cast 4 | 5 | from exegol.console.ConsoleFormat import richLen 6 | from exegol.config.EnvInfo import EnvInfo 7 | from exegol.utils.ExeLog import logger 8 | 9 | 10 | class Option: 11 | """This object allows to define and configure an argparse parameter""" 12 | 13 | def __init__(self, *args, dest: Optional[str] = None, **kwargs): 14 | """Generic class to handle Key:Value object directly from the constructor""" 15 | # Set arguments to the object to save every setting, these values will be sent to the argparser 16 | self.args = args 17 | self.kwargs = kwargs 18 | if dest is not None: 19 | self.kwargs["dest"] = dest 20 | self.dest = dest 21 | 22 | def __repr__(self) -> str: 23 | """This overload allows to format the name of the object. 24 | Mainly used by developers to easily identify objects""" 25 | return f"Option: {str(self.dest) if self.dest is not None else self.kwargs.get('metavar', 'Option not found')}" 26 | 27 | 28 | class GroupArg: 29 | """This object allows you to group a set of options within the same group""" 30 | 31 | def __init__(self, *options, title: Optional[str] = None, description: Optional[str] = None, 32 | is_global: bool = False): 33 | self.title = title 34 | self.description = description 35 | self.options: Tuple[Dict[str, Union[Option, bool]]] = cast(Tuple[Dict[str, Union[Option, bool]]], options) 36 | self.is_global = is_global 37 | 38 | def __repr__(self) -> str: 39 | """This overload allows to format the name of the object. 40 | Mainly used by developers to easily identify objects""" 41 | return f"GroupArg: {self.title}" 42 | 43 | 44 | class Command: 45 | """The Command class is the root of all CLI actions""" 46 | 47 | def __init__(self) -> None: 48 | # Root command usages (can be overwritten by subclasses to display different use cases) 49 | self._pre_usages = "[underline]To see specific examples run:[/underline][italic] exegol [cyan]command[/cyan] -h[/italic]" 50 | self._usages = { 51 | "Install (or build) an exegol image": "exegol install", 52 | "Open an exegol shell": "exegol start", 53 | "Show exegol images & containers": "exegol info", 54 | "Update an image": "exegol update", 55 | "See commands examples to execute": "exegol exec -h", 56 | "Remove a container": "exegol remove", 57 | "Uninstall an image": "exegol uninstall", 58 | "Stop a container": "exegol stop" 59 | } 60 | self._post_usages = "" 61 | 62 | # Name of the object 63 | self.name = type(self).__name__.lower() 64 | # Global parameters 65 | self.verify = Option("-k", "--insecure", 66 | dest="verify", 67 | action="store_false", 68 | default=True, 69 | required=False, 70 | help="Allow insecure server connections for web requests, " 71 | "e.g. when fetching info from DockerHub " 72 | "(default: [green]Secure[/green])") 73 | self.quiet = Option("-q", "--quiet", 74 | dest="quiet", 75 | action="store_true", 76 | default=False, 77 | help="Show no information at all") 78 | self.verbosity = Option("-v", "--verbose", 79 | dest="verbosity", 80 | action="count", 81 | default=0, 82 | help="Verbosity level (-v for verbose, -vv for advanced, -vvv for debug)") 83 | self.arch = Option("--arch", 84 | dest="arch", 85 | action="store", 86 | choices={"amd64", "arm64"}, 87 | default=EnvInfo.arch, 88 | help=f"Overwrite default image architecture (default, host's arch: [blue]{EnvInfo.arch}[/blue])") 89 | self.offline_mode = Option("--offline", 90 | dest="offline_mode", 91 | action="store_true", 92 | help=f"Run exegol in offline mode, no request will be made on internet (default: [red]Disable[/red])") 93 | # TODO review non-interactive mode 94 | # self.interactive_mode = Option("--non-interactive", 95 | # dest="interactive_mode", 96 | # action="store_false", 97 | # help="[red](WIP)[/red] Prevents Exegol from interactively requesting information. " 98 | # "If critical information is missing, an error will be raised.") 99 | 100 | # Main global group of argparse 101 | self.groupArgs = [ 102 | GroupArg({"arg": self.verbosity, "required": False}, 103 | # {"arg": self.interactive_mode, "required": False}, 104 | {"arg": self.quiet, "required": False}, 105 | {"arg": self.verify, "required": False}, 106 | {"arg": self.offline_mode, "required": False}, 107 | {"arg": self.arch, "required": False}, 108 | title="[blue]Optional arguments[/blue]", 109 | is_global=True) 110 | ] 111 | 112 | def __call__(self, *args, **kwargs): 113 | """This method is called by the main controller (ExegolController) 114 | to get the function, execute it and launch the action. 115 | This method must be overloaded in all child classes to ensure the correct execution of the thread""" 116 | logger.debug("The called command is : ", self.name) 117 | logger.debug("the object is", type(self).__name__) 118 | raise NotImplementedError 119 | 120 | def __repr__(self) -> str: 121 | """This overload allows to format the name of the object. 122 | Mainly used by developers to easily identify objects""" 123 | return self.name 124 | 125 | def populate(self, args: Namespace) -> None: 126 | """This method replaces the parsing objects (Option) with the result of the parsing""" 127 | for arg in vars(args).keys(): 128 | # Check if the argument exist in the current class 129 | if arg in self.__dict__: 130 | # If so, overwrite it with the corresponding value after parsing 131 | self.__setattr__(arg, vars(args)[arg]) 132 | 133 | def check_parameters(self) -> List[str]: 134 | """This method identifies the missing required parameters""" 135 | missingOption = [] 136 | for groupArg in self.groupArgs: 137 | for option in groupArg.options: 138 | if option.get("required", False): 139 | data = option.get("arg") 140 | assert data is not None and type(data) is Option 141 | if data.dest is not None and self.__dict__.get(data.dest) is None: 142 | missingOption.append(data.dest) 143 | return missingOption 144 | 145 | def formatEpilog(self) -> str: 146 | epilog = "[blue]Examples:[/blue]" + os.linesep 147 | epilog += self._pre_usages + os.linesep 148 | keys_len = {} 149 | # Replace [.*] rich tag for line length count 150 | for k in self._usages.keys(): 151 | keys_len[k] = richLen(k) 152 | max_key = max(keys_len.values()) 153 | for k, v in self._usages.items(): 154 | space = ' ' * (max_key - keys_len.get(k, 0) + 2) 155 | epilog += f" {k}:{space}[i]{v}[/i]{os.linesep}" 156 | epilog += self._post_usages + os.linesep 157 | return epilog 158 | -------------------------------------------------------------------------------- /exegol/console/cli/actions/ExegolParameters.py: -------------------------------------------------------------------------------- 1 | from exegol.console.cli.ExegolCompleter import HybridContainerImageCompleter, VoidCompleter, BuildProfileCompleter 2 | from exegol.console.cli.actions.Command import Command, Option, GroupArg 3 | from exegol.console.cli.actions.GenericParameters import ContainerCreation, ContainerSpawnShell, ContainerMultiSelector, ContainerSelector, ImageSelector, ImageMultiSelector, ContainerStart 4 | from exegol.manager.ExegolManager import ExegolManager 5 | from exegol.utils.ExeLog import logger 6 | 7 | 8 | class Start(Command, ContainerCreation, ContainerSpawnShell): 9 | """Automatically create, start / resume and enter an Exegol container""" 10 | 11 | def __init__(self) -> None: 12 | Command.__init__(self) 13 | ContainerCreation.__init__(self, self.groupArgs) 14 | ContainerSpawnShell.__init__(self, self.groupArgs) 15 | 16 | self._usages = { 17 | "Start interactively a container": "exegol start", 18 | "Create a [blue]demo[/blue] container using [bright_blue]full[/bright_blue] image": "exegol start [blue]demo[/blue] [bright_blue]full[/bright_blue]", 19 | "Spawn a shell from [blue]demo[/blue] container": "exegol start [blue]demo[/blue]", 20 | "Create a container [blue]test[/blue] with a custom shared workspace": "exegol start [blue]test[/blue] [bright_blue]full[/bright_blue] -w [magenta]./project/pentest/[/magenta]", 21 | "Create a container [blue]test[/blue] sharing the current working directory": "exegol start [blue]test[/blue] [bright_blue]full[/bright_blue] -cwd", 22 | "Create a container [blue]htb[/blue] with a VPN": "exegol start [blue]htb[/blue] [bright_blue]full[/bright_blue] --vpn [magenta]~/vpn/[/magenta][bright_magenta]lab_Dramelac.ovpn[/bright_magenta]", 23 | "Create a container [blue]app[/blue] with custom volume": "exegol start [blue]app[/blue] [bright_blue]full[/bright_blue] -V [bright_magenta]/var/app/[/bright_magenta]:[bright_magenta]/app/[/bright_magenta]", 24 | "Create a container [blue]app[/blue] with custom volume in [blue]ReadOnly[/blue]": "exegol start [blue]app[/blue] [bright_blue]full[/bright_blue] -V [bright_magenta]/var/app/[/bright_magenta]:[bright_magenta]/app/[/bright_magenta]:[blue]ro[/blue]", 25 | "Get a [blue]tmux[/blue] shell": "exegol start --shell [blue]tmux[/blue]", 26 | "Share a specific [blue]hardware device[/blue] [bright_black](e.g. Proxmark)[/bright_black]": "exegol start -d /dev/ttyACM0", 27 | "Share every [blue]USB device[/blue] connected to the host": "exegol start -d /dev/bus/usb/", 28 | } 29 | 30 | def __call__(self, *args, **kwargs): 31 | return ExegolManager.start 32 | 33 | 34 | class Stop(Command, ContainerMultiSelector): 35 | """Stop an Exegol container""" 36 | 37 | def __init__(self) -> None: 38 | Command.__init__(self) 39 | ContainerMultiSelector.__init__(self, self.groupArgs) 40 | 41 | self._usages = { 42 | "Stop interactively one or more containers": "exegol stop", 43 | "Stop [blue]demo[/blue]": "exegol stop [blue]demo[/blue]" 44 | } 45 | 46 | def __call__(self, *args, **kwargs): 47 | logger.debug("Running stop module") 48 | return ExegolManager.stop 49 | 50 | 51 | class Restart(Command, ContainerSelector, ContainerSpawnShell): 52 | """Restart an Exegol container""" 53 | 54 | def __init__(self) -> None: 55 | Command.__init__(self) 56 | ContainerSelector.__init__(self, self.groupArgs) 57 | ContainerSpawnShell.__init__(self, self.groupArgs) 58 | 59 | self._usages = { 60 | "Restart interactively one containers": "exegol restart", 61 | "Restart [blue]demo[/blue]": "exegol restart [blue]demo[/blue]" 62 | } 63 | 64 | def __call__(self, *args, **kwargs): 65 | logger.debug("Running restart module") 66 | return ExegolManager.restart 67 | 68 | 69 | class Install(Command, ImageSelector): 70 | """Install or build Exegol image""" 71 | 72 | def __init__(self) -> None: 73 | Command.__init__(self) 74 | ImageSelector.__init__(self, self.groupArgs) 75 | 76 | # Create container build arguments 77 | self.build_profile = Option("build_profile", 78 | metavar="BUILD_PROFILE", 79 | nargs="?", 80 | action="store", 81 | help="Select the build profile used to create a local image.", 82 | completer=BuildProfileCompleter) 83 | self.build_log = Option("--build-log", 84 | dest="build_log", 85 | metavar="LOGFILE_PATH", 86 | action="store", 87 | help="Write image building logs to a file.") 88 | self.build_path = Option("--build-path", 89 | dest="build_path", 90 | metavar="DOCKERFILES_PATH", 91 | action="store", 92 | help=f"Path to the dockerfiles and sources.") 93 | 94 | # Create group parameter for container selection 95 | self.groupArgs.append(GroupArg({"arg": self.build_profile, "required": False}, 96 | {"arg": self.build_log, "required": False}, 97 | {"arg": self.build_path, "required": False}, 98 | title="[bold cyan]Build[/bold cyan] [blue]specific options[/blue]")) 99 | 100 | self._usages = { 101 | "Install or build interactively an exegol image": "exegol install", 102 | "Install or update the [bright_blue]full[/bright_blue] image": "exegol install [bright_blue]full[/bright_blue]", 103 | "Build interactively a local image named [blue]myimage[/blue]": "exegol install [blue]myimage[/blue]", 104 | "Build the [blue]myimage[/blue] image based on the [bright_blue]full[/bright_blue] profile and log the operation": "exegol install [blue]myimage[/blue] [bright_blue]full[/bright_blue] --build-log /tmp/build.log", 105 | } 106 | 107 | def __call__(self, *args, **kwargs): 108 | logger.debug("Running install module") 109 | return ExegolManager.install 110 | 111 | 112 | class Update(Command, ImageSelector): 113 | """Update an Exegol image""" 114 | 115 | def __init__(self) -> None: 116 | Command.__init__(self) 117 | ImageSelector.__init__(self, self.groupArgs) 118 | 119 | self.skip_git = Option("--skip-git", 120 | dest="skip_git", 121 | action="store_true", 122 | help="Skip git updates (wrapper, image sources and exegol resources).") 123 | self.skip_images = Option("--skip-images", 124 | dest="skip_images", 125 | action="store_true", 126 | help="Skip images updates (exegol docker images).") 127 | 128 | # Create group parameter for container selection 129 | self.groupArgs.append(GroupArg({"arg": self.skip_git, "required": False}, 130 | {"arg": self.skip_images, "required": False}, 131 | title="[bold cyan]Update[/bold cyan] [blue]specific options[/blue]")) 132 | 133 | self._usages = { 134 | "Install or update interactively an exegol image": "exegol update", 135 | "Install or update the [bright_blue]full[/bright_blue] image": "exegol update [bright_blue]full[/bright_blue]" 136 | } 137 | 138 | def __call__(self, *args, **kwargs): 139 | logger.debug("Running update module") 140 | return ExegolManager.update 141 | 142 | 143 | class Uninstall(Command, ImageMultiSelector): 144 | """Remove Exegol [default not bold]image(s)[/default not bold]""" 145 | 146 | def __init__(self) -> None: 147 | Command.__init__(self) 148 | ImageMultiSelector.__init__(self, self.groupArgs) 149 | 150 | self.force_mode = Option("-F", "--force", 151 | dest="force_mode", 152 | action="store_true", 153 | help="Remove image without interactive user confirmation.") 154 | 155 | # Create group parameter for container selection 156 | self.groupArgs.append(GroupArg({"arg": self.force_mode, "required": False}, 157 | title="[bold cyan]Uninstall[/bold cyan] [blue]specific options[/blue]")) 158 | 159 | self._usages = { 160 | "Uninstall interactively one or more exegol images": "exegol uninstall", 161 | "Uninstall the [bright_blue]dev[/bright_blue] image": "exegol uninstall [bright_blue]dev[/bright_blue]" 162 | } 163 | 164 | def __call__(self, *args, **kwargs): 165 | logger.debug("Running uninstall module") 166 | return ExegolManager.uninstall 167 | 168 | 169 | class Remove(Command, ContainerMultiSelector): 170 | """Remove Exegol [default not bold]container(s)[/default not bold]""" 171 | 172 | def __init__(self) -> None: 173 | Command.__init__(self) 174 | ContainerMultiSelector.__init__(self, self.groupArgs) 175 | 176 | self.force_mode = Option("-F", "--force", 177 | dest="force_mode", 178 | action="store_true", 179 | help="Remove container without interactive user confirmation.") 180 | 181 | # Create group parameter for container selection 182 | self.groupArgs.append(GroupArg({"arg": self.force_mode, "required": False}, 183 | title="[bold cyan]Remove[/bold cyan] [blue]specific options[/blue]")) 184 | 185 | self._usages = { 186 | "Remove interactively one or more containers": "exegol remove", 187 | "Remove the [blue]demo[/blue] container": "exegol remove [blue]demo[/blue]" 188 | } 189 | 190 | def __call__(self, *args, **kwargs): 191 | logger.debug("Running remove module") 192 | return ExegolManager.remove 193 | 194 | 195 | class Exec(Command, ContainerCreation, ContainerStart): 196 | """Execute a command on an Exegol container""" 197 | 198 | def __init__(self) -> None: 199 | Command.__init__(self) 200 | ContainerCreation.__init__(self, self.groupArgs) 201 | ContainerStart.__init__(self, self.groupArgs) 202 | 203 | # Overwrite default selectors 204 | for group in self.groupArgs.copy(): 205 | # Find group containing default selector to remove them 206 | for parameter in group.options: 207 | if parameter.get('arg') == self.containertag or parameter.get('arg') == self.imagetag: 208 | # Removing default GroupArg selector 209 | self.groupArgs.remove(group) 210 | break 211 | # Removing default selector objects 212 | self.containertag = None 213 | self.imagetag = None 214 | 215 | self.selector = Option("selector", 216 | metavar="CONTAINER or IMAGE", 217 | nargs='?', 218 | action="store", 219 | help="Tag used to target an Exegol container (by default) or an image (if --tmp is set).", 220 | completer=HybridContainerImageCompleter) 221 | 222 | # Custom parameters 223 | self.exec = Option("exec", 224 | metavar="COMMAND", 225 | nargs="+", 226 | action="store", 227 | help="Execute a single command in the exegol container.", 228 | completer=VoidCompleter) 229 | self.daemon = Option("-b", "--background", 230 | action="store_true", 231 | dest="daemon", 232 | help="Executes the command in background as a daemon " 233 | "(default: [red not italic]False[/red not italic])") 234 | self.tmp = Option("--tmp", 235 | action="store_true", 236 | dest="tmp", 237 | help="Creates a dedicated and temporary container to execute the command " 238 | "(default: [red not italic]False[/red not italic])") 239 | 240 | # Create group parameter for container selection 241 | self.groupArgs.append(GroupArg({"arg": self.selector, "required": False}, 242 | {"arg": self.exec, "required": False}, 243 | {"arg": self.daemon, "required": False}, 244 | {"arg": self.tmp, "required": False}, 245 | title="[bold cyan]Exec[/bold cyan] [blue]specific options[/blue]")) 246 | 247 | self._usages = { 248 | "Execute the command [magenta]bloodhound[/magenta] in the container [blue]demo[/blue]": 249 | "exegol exec [blue]demo[/blue] [magenta]bloodhound[/magenta]", 250 | "Execute the command [magenta]'nmap -h'[/magenta] with console output": 251 | "exegol exec -v [blue]demo[/blue] [magenta]'nmap -h'[/magenta]", 252 | "Execute a command in [green]background[/green] within the [blue]demo[/blue] container": 253 | "exegol exec [green]-b[/green] [blue]demo[/blue] [magenta]bloodhound[/magenta]", 254 | "Execute the command [magenta]bloodhound[/magenta] in a temporary container based on the [bright_blue]full[/bright_blue] image": 255 | "exegol exec --tmp [bright_blue]full[/bright_blue] [magenta]bloodhound[/magenta]", 256 | "Execute a command in [green]background[/green] with a temporary container": 257 | "exegol exec [green]-b[/green] --tmp [bright_blue]full[/bright_blue] [magenta]bloodhound[/magenta]", 258 | "Execute the command [magenta]wireshark[/magenta] with [orange3]network admin[/orange3] privileged": 259 | "exegol exec [green]-b[/green] --tmp --disable-my-resources --cap [orange3]NET_ADMIN[/orange3] [bright_blue]full[/bright_blue] [magenta]wireshark[/magenta]", 260 | } 261 | 262 | def __call__(self, *args, **kwargs): 263 | logger.debug("Running exec module") 264 | return ExegolManager.exec 265 | 266 | 267 | class Info(Command, ContainerSelector): 268 | """Show info on containers and images (local & remote)""" 269 | 270 | def __init__(self) -> None: 271 | Command.__init__(self) 272 | ContainerSelector.__init__(self, self.groupArgs) 273 | 274 | self._usages = { 275 | "Print containers and images essentials information": "exegol info", 276 | "Print the detailed configuration of the [blue]demo[/blue] container": "exegol info [blue]demo[/blue]", 277 | "Print verbose information": "exegol info [yellow3]-v[/yellow3]", 278 | "Print advanced information": "exegol info [yellow3]-vv[/yellow3]", 279 | "Print debug information": "exegol info [yellow3]-vvv[/yellow3]" 280 | } 281 | 282 | def __call__(self, *args, **kwargs): 283 | return ExegolManager.info 284 | 285 | 286 | class Version(Command): 287 | """Print current Exegol version""" 288 | 289 | def __call__(self, *args, **kwargs): 290 | return lambda: None 291 | -------------------------------------------------------------------------------- /exegol/console/cli/actions/GenericParameters.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from argcomplete.completers import EnvironCompleter, DirectoriesCompleter, FilesCompleter 4 | 5 | from exegol.config.UserConfig import UserConfig 6 | from exegol.console.cli.ExegolCompleter import ContainerCompleter, ImageCompleter, VoidCompleter, DesktopConfigCompleter 7 | from exegol.console.cli.actions.Command import Option, GroupArg 8 | 9 | 10 | class ContainerSelector: 11 | """Generic parameter class for container selection""" 12 | 13 | def __init__(self, groupArgs: List[GroupArg]): 14 | # Create container selector arguments 15 | self.containertag: Optional[Option] = Option("containertag", 16 | metavar="CONTAINER", 17 | nargs='?', 18 | action="store", 19 | help="Tag used to target an Exegol container", 20 | completer=ContainerCompleter) 21 | 22 | # Create group parameter for container selection 23 | groupArgs.append(GroupArg({"arg": self.containertag, "required": False}, 24 | title="[blue]Container selection options[/blue]")) 25 | 26 | 27 | class ContainerMultiSelector: 28 | """Generic parameter class for container multi selection""" 29 | 30 | def __init__(self, groupArgs: List[GroupArg]): 31 | # Create container selector arguments 32 | self.multicontainertag = Option("multicontainertag", 33 | metavar="CONTAINER", 34 | nargs='*', 35 | action="store", 36 | help="Tag used to target one or more Exegol containers", 37 | completer=ContainerCompleter) 38 | 39 | # Create group parameter for container multi selection 40 | groupArgs.append(GroupArg({"arg": self.multicontainertag, "required": False}, 41 | title="[blue]Containers selection options[/blue]")) 42 | 43 | 44 | class ContainerStart: 45 | """Generic parameter class for container selection. 46 | This generic class is used by start, restart and exec actions""" 47 | 48 | def __init__(self, groupArgs: List[GroupArg]): 49 | # Create options on container start 50 | self.envs = Option("-e", "--env", 51 | action="append", 52 | default=[], 53 | dest="envs", 54 | help="And an environment variable on Exegol (format: --env KEY=value). The variables " 55 | "configured during the creation of the container will be persistent in all shells. " 56 | "If the container already exists, the variable will be present only in the current shell", 57 | completer=EnvironCompleter) 58 | 59 | # Create group parameter for container options at start 60 | groupArgs.append(GroupArg({"arg": self.envs, "required": False}, 61 | title="[blue]Container start options[/blue]")) 62 | 63 | 64 | class ContainerSpawnShell(ContainerStart): 65 | """Generic parameter class to spawn a shell on an exegol container. 66 | This generic class is used by start and restart""" 67 | 68 | def __init__(self, groupArgs: List[GroupArg]): 69 | # Spawn container shell arguments 70 | self.shell = Option("-s", "--shell", 71 | dest="shell", 72 | action="store", 73 | choices=UserConfig.start_shell_options, 74 | default=UserConfig().default_start_shell, 75 | help=f"Select a shell environment to launch at startup (Default: [blue]{UserConfig().default_start_shell}[/blue])") 76 | 77 | self.log = Option("-l", "--log", 78 | dest="log", 79 | action="store_true", 80 | default=False, 81 | help="Enable shell logging (commands and outputs) on exegol to /workspace/logs/ (default: [red]Disabled[/red])") 82 | self.log_method = Option("--log-method", 83 | dest="log_method", 84 | action="store", 85 | choices=UserConfig.shell_logging_method_options, 86 | default=UserConfig().shell_logging_method, 87 | help=f"Select a shell logging method used to record the session (default: [blue]{UserConfig().shell_logging_method}[/blue])") 88 | self.log_compress = Option("--log-compress", 89 | dest="log_compress", 90 | action="store_true", 91 | default=False, 92 | help=f"Enable or disable the automatic compression of log files at the end of the session (default: {'[green]Enabled[/green]' if UserConfig().shell_logging_compress else '[red]Disabled[/red]'})") 93 | 94 | # Group dedicated to shell logging feature 95 | groupArgs.append(GroupArg({"arg": self.log, "required": False}, 96 | {"arg": self.log_method, "required": False}, 97 | {"arg": self.log_compress, "required": False}, 98 | title="[blue]Container creation Shell logging options[/blue]")) 99 | 100 | ContainerStart.__init__(self, groupArgs) 101 | 102 | # Create group parameter for container selection 103 | groupArgs.append(GroupArg({"arg": self.shell, "required": False}, 104 | title="[bold cyan]Start[/bold cyan] [blue]specific options[/blue]")) 105 | 106 | 107 | class ImageSelector: 108 | """Generic parameter class for image selection""" 109 | 110 | def __init__(self, groupArgs: List[GroupArg]): 111 | # Create image selector arguments 112 | self.imagetag: Optional[Option] = Option("imagetag", 113 | metavar="IMAGE", 114 | nargs='?', 115 | action="store", 116 | help="Tag used to target an Exegol image", 117 | completer=ImageCompleter) 118 | 119 | # Create group parameter for image selection 120 | groupArgs.append(GroupArg({"arg": self.imagetag, "required": False}, 121 | title="[blue]Image selection options[/blue]")) 122 | 123 | 124 | class ImageMultiSelector: 125 | """Generic parameter class for image multi selection""" 126 | 127 | def __init__(self, groupArgs: List[GroupArg]): 128 | # Create image multi selector arguments 129 | self.multiimagetag = Option("multiimagetag", 130 | metavar="IMAGE", 131 | nargs='*', 132 | action="store", 133 | help="Tag used to target one or more Exegol images", 134 | completer=ImageCompleter) 135 | 136 | # Create group parameter for image multi selection 137 | groupArgs.append(GroupArg({"arg": self.multiimagetag, "required": False}, 138 | title="[blue]Images selection options[/blue]")) 139 | 140 | 141 | class ContainerCreation(ContainerSelector, ImageSelector): 142 | """Generic parameter class for container creation""" 143 | 144 | def __init__(self, groupArgs: List[GroupArg]): 145 | # Init parents : ContainerStart > ContainerSelector 146 | ContainerSelector.__init__(self, groupArgs) 147 | ImageSelector.__init__(self, groupArgs) 148 | 149 | self.X11 = Option("--disable-X11", 150 | action="store_false", 151 | default=True, 152 | dest="X11", 153 | help="Disable X11 sharing to run GUI-based applications (default: [green]Enabled[/green])") 154 | self.my_resources = Option("--disable-my-resources", 155 | action="store_false", 156 | default=True, 157 | dest="my_resources", 158 | help=f"Disable the mount of the my-resources (/opt/my-resources) from the host ({UserConfig().my_resources_path}) (default: [green]Enabled[/green])") 159 | self.exegol_resources = Option("--disable-exegol-resources", 160 | action="store_false", 161 | default=True, 162 | dest="exegol_resources", 163 | help=f"Disable the mount of the exegol resources (/opt/resources) from the host ({UserConfig().exegol_resources_path}) (default: [green]Enabled[/green])") 164 | self.host_network = Option("--disable-shared-network", 165 | action="store_false", 166 | default=True, 167 | dest="host_network", 168 | help="Disable the sharing of the host's network interfaces with exegol (default: [green]Enabled[/green])") 169 | self.share_timezone = Option("--disable-shared-timezones", 170 | action="store_false", 171 | default=True, 172 | dest="share_timezone", 173 | help="Disable the sharing of the host's time and timezone configuration with exegol (default: [green]Enabled[/green])") 174 | self.mount_current_dir = Option("-cwd", "--cwd-mount", 175 | dest="mount_current_dir", 176 | action="store_true", 177 | default=False, 178 | help="This option is a shortcut to set the /workspace folder to the user's current working directory") 179 | self.workspace_path = Option("-w", "--workspace", 180 | dest="workspace_path", 181 | action="store", 182 | help="The specified host folder will be linked to the /workspace folder in the container", 183 | completer=DirectoriesCompleter()) 184 | self.update_fs_perms = Option("-fs", "--update-fs", 185 | action="store_true", 186 | default=False, 187 | dest="update_fs_perms", 188 | help=f"Modifies the permissions of folders and sub-folders shared in your workspace to access the files created within the container using your host user account. " 189 | f"(default: {'[green]Enabled[/green]' if UserConfig().auto_update_workspace_fs else '[red]Disabled[/red]'})") 190 | self.volumes = Option("-V", "--volume", 191 | action="append", 192 | default=[], 193 | dest="volumes", 194 | help="Share a new volume between host and exegol (format: --volume /path/on/host/:/path/in/container/[blue][:ro|rw][/blue])") 195 | self.ports = Option("-p", "--port", 196 | action="append", 197 | default=[], 198 | dest="ports", 199 | help="Share a network port between host and exegol (format: --port [<host_ipv4>:]<host_port>[:<container_port>][:<protocol>]. This configuration will disable the shared network with the host.", 200 | completer=VoidCompleter) 201 | self.hostname = Option("--hostname", 202 | dest="hostname", 203 | default=None, 204 | action="store", 205 | help="Set a custom hostname to the exegol container (default: exegol-<name>)", 206 | completer=VoidCompleter) 207 | self.capabilities = Option("--cap", 208 | dest="capabilities", 209 | metavar='CAP', # Do not display available choices 210 | action="append", 211 | default=[], 212 | choices={"NET_ADMIN", "NET_BROADCAST", "SYS_MODULE", "SYS_PTRACE", "SYS_RAWIO", 213 | "SYS_ADMIN", "LINUX_IMMUTABLE", "MAC_ADMIN", "SYSLOG"}, 214 | help="[orange3](dangerous)[/orange3] Capabilities allow to add [orange3]specific[/orange3] privileges to the container " 215 | "(e.g. need to mount volumes, perform low-level operations on the network, etc).") 216 | self.privileged = Option("--privileged", 217 | dest="privileged", 218 | action="store_true", 219 | default=False, 220 | help="[orange3](dangerous)[/orange3] Give [red]ALL[/red] admin privileges to the container when it is created " 221 | "(if the need is specifically identified, consider adding capabilities instead).") 222 | self.devices = Option("-d", "--device", 223 | dest="devices", 224 | default=[], 225 | action="append", 226 | help="Add host [default not bold]device(s)[/default not bold] at the container creation (example: -d /dev/ttyACM0 -d /dev/bus/usb/)") 227 | 228 | self.comment = Option("--comment", 229 | dest="comment", 230 | action="store", 231 | help="The specified comment will be added to the container info", 232 | completer=VoidCompleter) 233 | 234 | groupArgs.append(GroupArg({"arg": self.workspace_path, "required": False}, 235 | {"arg": self.mount_current_dir, "required": False}, 236 | {"arg": self.update_fs_perms, "required": False}, 237 | {"arg": self.volumes, "required": False}, 238 | {"arg": self.ports, "required": False}, 239 | {"arg": self.hostname, "required": False}, 240 | {"arg": self.capabilities, "required": False}, 241 | {"arg": self.privileged, "required": False}, 242 | {"arg": self.devices, "required": False}, 243 | {"arg": self.X11, "required": False}, 244 | {"arg": self.my_resources, "required": False}, 245 | {"arg": self.exegol_resources, "required": False}, 246 | {"arg": self.host_network, "required": False}, 247 | {"arg": self.share_timezone, "required": False}, 248 | {"arg": self.comment, "required": False}, 249 | title="[blue]Container creation options[/blue]")) 250 | 251 | self.vpn = Option("--vpn", 252 | dest="vpn", 253 | default=None, 254 | action="store", 255 | help="Setup an OpenVPN connection at the container creation (example: --vpn /home/user/vpn/conf.ovpn)", 256 | completer=FilesCompleter(["ovpn"], directories=True)) 257 | self.vpn_auth = Option("--vpn-auth", 258 | dest="vpn_auth", 259 | default=None, 260 | action="store", 261 | help="Enter the credentials with a file (first line: username, second line: password) to establish the VPN connection automatically (example: --vpn-auth /home/user/vpn/auth.txt)") 262 | 263 | groupArgs.append(GroupArg({"arg": self.vpn, "required": False}, 264 | {"arg": self.vpn_auth, "required": False}, 265 | title="[blue]Container creation VPN options[/blue]")) 266 | 267 | self.desktop = Option("--desktop", 268 | dest="desktop", 269 | action="store_true", 270 | default=False, 271 | help=f"Enable or disable the Exegol desktop feature (default: {'[green]Enabled[/green]' if UserConfig().desktop_default_enable else '[red]Disabled[/red]'})") 272 | self.desktop_config = Option("--desktop-config", 273 | dest="desktop_config", 274 | default="", 275 | action="store", 276 | help=f"Configure your exegol desktop ([blue]{'[/blue] or [blue]'.join(UserConfig.desktop_available_proto)}[/blue]) and its exposure " 277 | f"(format: [blue]proto[:ip[:port]][/blue]) " 278 | f"(default: [blue]{UserConfig().desktop_default_proto}[/blue]:[blue]{'127.0.0.1' if UserConfig().desktop_default_localhost else '0.0.0.0'}[/blue]:[blue]<random>[/blue])", 279 | completer=DesktopConfigCompleter) 280 | groupArgs.append(GroupArg({"arg": self.desktop, "required": False}, 281 | {"arg": self.desktop_config, "required": False}, 282 | title="[blue]Container creation Desktop options[/blue] [spring_green1](beta)[/spring_green1]")) 283 | -------------------------------------------------------------------------------- /exegol/console/cli/actions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePorgs/Exegol/3ef46e7fff8b45617dead7c6d5ea83635d609834/exegol/console/cli/actions/__init__.py -------------------------------------------------------------------------------- /exegol/exceptions/ExegolExceptions.py: -------------------------------------------------------------------------------- 1 | # Exceptions specific to the successful operation of exegol 2 | class ObjectNotFound(Exception): 3 | """Custom exception when a specific container do not exist""" 4 | pass 5 | 6 | 7 | class ProtocolNotSupported(Exception): 8 | """Custom exception when a specific network protocol is not supported""" 9 | pass 10 | 11 | 12 | class CancelOperation(Exception): 13 | """Custom exception when an error occurred and the operation must be canceled ou skipped""" 14 | pass 15 | -------------------------------------------------------------------------------- /exegol/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePorgs/Exegol/3ef46e7fff8b45617dead7c6d5ea83635d609834/exegol/exceptions/__init__.py -------------------------------------------------------------------------------- /exegol/manager/ExegolController.py: -------------------------------------------------------------------------------- 1 | import http 2 | import logging 3 | 4 | try: 5 | import docker 6 | import git 7 | import requests 8 | import urllib3 9 | 10 | from exegol.utils.ExeLog import logger, ExeLog, console 11 | from exegol.utils.DockerUtils import DockerUtils 12 | from exegol.console.cli.ParametersManager import ParametersManager 13 | from exegol.console.cli.actions.ExegolParameters import Command 14 | from exegol.manager.ExegolManager import ExegolManager 15 | except ModuleNotFoundError as e: 16 | print("Mandatory dependencies are missing:", e) 17 | print("Please install them with python3 -m pip install --upgrade -r requirements.txt") 18 | exit(1) 19 | except ImportError as e: 20 | print("An error occurred while loading the dependencies!") 21 | print() 22 | if "git executable" in e.msg: 23 | print("Git is missing in your PATH, it must be installed locally on your computer.") 24 | print() 25 | print("Details:") 26 | print(e) 27 | exit(1) 28 | 29 | 30 | class ExegolController: 31 | """Main controller of exegol""" 32 | 33 | # Get action selected by user 34 | # (ParametersManager must be loaded from ExegolController first to load every Command subclass) 35 | __action: Command = ParametersManager().getCurrentAction() 36 | 37 | @classmethod 38 | def call_action(cls) -> None: 39 | """Dynamically retrieve the main function corresponding to the action selected by the user 40 | and execute it on the main thread""" 41 | ExegolManager.print_version() 42 | DockerUtils() # Init dockerutils 43 | ExegolManager.print_debug_banner() 44 | # Check for missing parameters 45 | missing_params = cls.__action.check_parameters() 46 | if len(missing_params) == 0: 47 | # Fetch main operation function 48 | main_action = cls.__action() 49 | # Execute main function 50 | main_action() 51 | else: 52 | # TODO review required parameters 53 | logger.error(f"These parameters are mandatory but missing: {','.join(missing_params)}") 54 | 55 | 56 | def print_exception_banner() -> None: 57 | logger.error("It seems that something unexpected happened ...") 58 | logger.error("To draw our attention to the problem and allow us to fix it, you can share your error with us " 59 | "(by [orange3]copying and pasting[/orange3] it with this syntax: ``` <error> ```) " 60 | "by creating a GitHub issue at this address: https://github.com/ThePorgs/Exegol/issues") 61 | logger.success("Thank you for your collaboration!") 62 | 63 | 64 | def main() -> None: 65 | """Exegol main console entrypoint""" 66 | try: 67 | # Set logger verbosity depending on user input 68 | ExeLog.setVerbosity(ParametersManager().verbosity, ParametersManager().quiet) 69 | # Start Main controller & Executing action selected by user CLI 70 | ExegolController.call_action() 71 | except KeyboardInterrupt: 72 | logger.empty_line() 73 | logger.info("Exiting") 74 | except git.exc.GitCommandError as git_error: 75 | print_exception_banner() 76 | # Printing git stderr as raw to avoid any Rich parsing error 77 | logger.debug("Full git output:") 78 | logger.raw(git_error, level=logging.DEBUG) 79 | logger.empty_line() 80 | error = git_error.stderr.strip().split(": ")[-1].strip("'") 81 | logger.error("Git error received:") 82 | # Printing git error as raw to avoid any Rich parsing error 83 | logger.raw(error, level=logging.ERROR) 84 | logger.empty_line() 85 | logger.critical(f"A critical error occurred while running this git command: {' '.join(git_error.command)}") 86 | except Exception: 87 | print_exception_banner() 88 | console.print_exception(show_locals=True, suppress=[docker, requests, git, urllib3, http]) 89 | exit(1) 90 | -------------------------------------------------------------------------------- /exegol/manager/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePorgs/Exegol/3ef46e7fff8b45617dead7c6d5ea83635d609834/exegol/manager/__init__.py -------------------------------------------------------------------------------- /exegol/model/CacheModels.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import List, Optional, Dict, Union, Sequence, cast 3 | 4 | from exegol.config.ConstantConfig import ConstantConfig 5 | from exegol.utils.ExeLog import logger 6 | 7 | 8 | class MetadataCacheModel: 9 | """MetadataCacheModel store a timestamp to compare with the last update""" 10 | 11 | def __init__(self, last_check=None, time_format: str = "%d/%m/%Y"): 12 | self.__TIME_FORMAT = time_format 13 | if last_check is None: 14 | last_check = datetime.date.today().strftime(self.__TIME_FORMAT) 15 | self.last_check: str = last_check 16 | 17 | def update_last_check(self) -> None: 18 | self.last_check = datetime.date.today().strftime(self.__TIME_FORMAT) 19 | 20 | def get_last_check(self) -> datetime.datetime: 21 | return datetime.datetime.strptime(self.last_check, self.__TIME_FORMAT) 22 | 23 | def get_last_check_text(self) -> str: 24 | return self.last_check 25 | 26 | def is_outdated(self, days: int = 15, hours: int = 0) -> bool: 27 | """Check if the cache must be considered as expired.""" 28 | now = datetime.datetime.now() 29 | last_check = self.get_last_check() 30 | if last_check > now: 31 | logger.debug("Incoherent last check date detected. Metadata must be updated.") 32 | return True 33 | # Check if the last update is older than the max delay configures (by default, return True after at least 15 days) 34 | return (last_check + datetime.timedelta(days=days, hours=hours)) < now 35 | 36 | 37 | class ImageCacheModel: 38 | """This model store every important information for image caching.""" 39 | 40 | def __init__(self, name: str, last_version: str, digest: str, source: str): 41 | self.name: str = name 42 | self.last_version: str = last_version 43 | self.digest: str = digest 44 | self.source: str = source 45 | 46 | def __str__(self) -> str: 47 | return f"{self.name}:{self.last_version} ({self.source}: {self.digest})" 48 | 49 | def __repr__(self) -> str: 50 | return str(self) 51 | 52 | 53 | class ImagesCacheModel: 54 | """This model can store multiple image and the date of the last update""" 55 | 56 | def __init__(self, data: Sequence[Union[ImageCacheModel, Dict]], metadata: Optional[Dict] = None): 57 | # An old default date will be used until data are provided 58 | default_date: Optional[str] = "01/01/1990" if len(data) == 0 else None 59 | # Create or load (meta)data 60 | self.metadata: MetadataCacheModel = MetadataCacheModel(default_date) if metadata is None else MetadataCacheModel(**metadata) 61 | self.data: List[ImageCacheModel] = [] 62 | if len(data) > 0: 63 | if type(data[0]) is dict: 64 | for img in data: 65 | self.data.append(ImageCacheModel(**cast(Dict, img))) 66 | elif type(data[0]) is ImageCacheModel: 67 | self.data = cast(List[ImageCacheModel], data) 68 | else: 69 | raise NotImplementedError 70 | 71 | def __str__(self) -> str: 72 | return f"{len(self.data)} images ({self.metadata.last_check})" 73 | 74 | def __repr__(self) -> str: 75 | return str(self) 76 | 77 | 78 | class WrapperCacheModel: 79 | """Caching wrapper update information (last version / last update)""" 80 | 81 | def __init__(self, last_version: Optional[str] = None, current_version: Optional[str] = None, metadata: Optional[Dict] = None): 82 | default_date: Optional[str] = None 83 | if last_version is None: 84 | last_version = ConstantConfig.version 85 | default_date = "01/01/1990" 86 | if current_version is None: 87 | current_version = ConstantConfig.version 88 | self.metadata: MetadataCacheModel = MetadataCacheModel(default_date) if metadata is None else MetadataCacheModel(**metadata) 89 | self.last_version: str = last_version 90 | self.current_version: str = current_version 91 | 92 | def __str__(self) -> str: 93 | return f"{self.current_version} -> {self.last_version} ({self.metadata.last_check})" 94 | 95 | def __repr__(self) -> str: 96 | return str(self) 97 | 98 | 99 | class CacheDB: 100 | """Main object of the exegol cache""" 101 | 102 | def __init__(self) -> None: 103 | self.wrapper: WrapperCacheModel = WrapperCacheModel() 104 | self.images: ImagesCacheModel = ImagesCacheModel([]) 105 | 106 | def load(self, wrapper: Dict, images: Dict) -> None: 107 | """Load the CacheDB data from a raw Dict object""" 108 | self.wrapper = WrapperCacheModel(**wrapper) 109 | self.images = ImagesCacheModel(**images) 110 | -------------------------------------------------------------------------------- /exegol/model/ExegolContainerTemplate.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | 4 | from rich.prompt import Prompt 5 | 6 | from exegol.config.EnvInfo import EnvInfo 7 | from exegol.model.ContainerConfig import ContainerConfig 8 | from exegol.model.ExegolImage import ExegolImage 9 | 10 | 11 | class ExegolContainerTemplate: 12 | """Exegol template class used to create a new container""" 13 | 14 | def __init__(self, name: Optional[str], config: ContainerConfig, image: ExegolImage, hostname: Optional[str] = None, new_container: bool = True): 15 | if name is None: 16 | name = Prompt.ask("[bold blue][?][/bold blue] Enter the name of your new exegol container", default="default") 17 | assert name is not None 18 | if (EnvInfo.isWindowsHost() or EnvInfo.isMacHost()) and not name.startswith("exegol-"): 19 | # Force container as lowercase because the filesystem of windows / mac are case-insensitive => https://github.com/ThePorgs/Exegol/issues/167 20 | name = name.lower() 21 | self.container_name: str = name if name.startswith("exegol-") else f'exegol-{name}' 22 | self.name: str = name.replace('exegol-', '') 23 | self.image: ExegolImage = image 24 | self.config: ContainerConfig = config 25 | if hostname: 26 | self.config.hostname = hostname 27 | if new_container: 28 | self.config.addEnv(ContainerConfig.ExegolEnv.exegol_name.value, self.container_name) 29 | else: 30 | self.config.hostname = self.container_name 31 | 32 | def __str__(self) -> str: 33 | """Default object text formatter, debug only""" 34 | return f"{self.name} - {self.image.getName()}{os.linesep}{self.config}" 35 | 36 | def prepare(self) -> None: 37 | """Prepare the model before creating the docker container""" 38 | self.config.prepareShare(self.name) 39 | 40 | def rollback(self) -> None: 41 | """Rollback change in case of container creation fail.""" 42 | self.config.rollback_preparation(self.name) 43 | 44 | def getDisplayName(self) -> str: 45 | """Getter of the container's name for TUI purpose""" 46 | if self.container_name != self.config.hostname: 47 | return f"{self.name} [bright_black]({self.config.hostname})[/bright_black]" 48 | return self.name 49 | 50 | def getTextStatus(self) -> str: 51 | return "" 52 | -------------------------------------------------------------------------------- /exegol/model/ExegolModules.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional, Union 3 | 4 | from exegol.config.ConstantConfig import ConstantConfig 5 | from exegol.config.UserConfig import UserConfig 6 | from exegol.console.ExegolPrompt import Confirm 7 | from exegol.console.cli.ParametersManager import ParametersManager 8 | from exegol.exceptions.ExegolExceptions import CancelOperation 9 | from exegol.utils.ExeLog import logger 10 | from exegol.utils.GitUtils import GitUtils 11 | from exegol.utils.MetaSingleton import MetaSingleton 12 | 13 | 14 | class ExegolModules(metaclass=MetaSingleton): 15 | """Singleton class dedicated to the centralized management of the project modules""" 16 | 17 | def __init__(self) -> None: 18 | """Init project git modules to None until their first call""" 19 | # Git modules 20 | self.__git_wrapper: Optional[GitUtils] = None 21 | self.__git_source: Optional[GitUtils] = None 22 | self.__git_resources: Optional[GitUtils] = None 23 | 24 | # Git loading mode 25 | self.__wrapper_fast_loaded = False 26 | 27 | def getWrapperGit(self, fast_load: bool = False) -> GitUtils: 28 | """GitUtils local singleton getter. 29 | Set fast_load to True to disable submodule init/update.""" 30 | # If the module have been previously fast loaded and must be reuse later in standard mode, it can be recreated 31 | if self.__git_wrapper is None or (not fast_load and self.__wrapper_fast_loaded): 32 | self.__wrapper_fast_loaded = fast_load 33 | self.__git_wrapper = GitUtils(skip_submodule_update=fast_load) 34 | return self.__git_wrapper 35 | 36 | def getSourceGit(self, fast_load: bool = False, skip_install: bool = False) -> GitUtils: 37 | """GitUtils source submodule singleton getter. 38 | Set fast_load to True to disable submodule init/update. 39 | Set skip_install to skip to installation process of the modules if not available. 40 | if skip_install is NOT set, the CancelOperation exception is raised if the installation failed.""" 41 | if self.__git_source is None: 42 | self.__git_source = GitUtils(UserConfig().exegol_images_path, "images", 43 | skip_submodule_update=fast_load) 44 | if not self.__git_source.isAvailable and not skip_install: 45 | self.__init_images_repo() 46 | return self.__git_source 47 | 48 | def getResourcesGit(self, fast_load: bool = False, skip_install: bool = False) -> GitUtils: 49 | """GitUtils resource repo/submodule singleton getter. 50 | Set fast_load to True to disable submodule init/update. 51 | Set skip_install to skip to installation process of the modules if not available. 52 | if skip_install is NOT set, the CancelOperation exception is raised if the installation failed.""" 53 | if self.__git_resources is None: 54 | self.__git_resources = GitUtils(UserConfig().exegol_resources_path, "resources", "", 55 | skip_submodule_update=fast_load) 56 | if not self.__git_resources.isAvailable and not skip_install and UserConfig().enable_exegol_resources: 57 | self.__init_resources_repo() 58 | return self.__git_resources 59 | 60 | def __init_images_repo(self) -> None: 61 | """Initialization procedure of exegol images module. 62 | Raise CancelOperation if the initialization failed.""" 63 | if ParametersManager().offline_mode: 64 | logger.error("It's not possible to install 'Exegol Images' in offline mode. Skipping the operation.") 65 | raise CancelOperation 66 | # If git wrapper is ready and exegol images location is the corresponding submodule, running submodule update 67 | # if not, git clone resources 68 | if ConstantConfig.git_source_installation and self.getWrapperGit(fast_load=True).isAvailable: 69 | # When resources are load from git submodule, git objects are stored in the root .git directory 70 | if self.getWrapperGit(fast_load=True).submoduleSourceUpdate("exegol-images"): 71 | self.__git_source = None 72 | self.getSourceGit() 73 | else: 74 | # Error during install, raise error to avoid update process 75 | raise CancelOperation 76 | else: 77 | assert self.__git_source is not None 78 | if not self.__git_source.clone(ConstantConfig.EXEGOL_IMAGES_REPO): 79 | # Error during install, raise error to avoid update process 80 | raise CancelOperation 81 | 82 | def __init_resources_repo(self) -> None: 83 | """Initialization procedure of exegol resources module. 84 | Raise CancelOperation if the initialization failed.""" 85 | if ParametersManager().offline_mode: 86 | logger.error("It's not possible to install 'Exegol resources' in offline mode. Skipping the operation.") 87 | raise CancelOperation 88 | if Confirm("Do you want to download exegol resources? (~1G)", True): 89 | # If git wrapper is ready and exegol resources location is the corresponding submodule, running submodule update 90 | # if not, git clone resources 91 | if UserConfig().exegol_resources_path == ConstantConfig.src_root_path_obj / 'exegol-resources' and \ 92 | self.getWrapperGit().isAvailable: 93 | # When resources are load from git submodule, git objects are stored in the root .git directory 94 | self.__warningExcludeFolderAV(ConstantConfig.src_root_path_obj) 95 | if self.getWrapperGit().submoduleSourceUpdate("exegol-resources"): 96 | self.__git_resources = None 97 | self.getResourcesGit() 98 | else: 99 | # Error during install, raise error to avoid update process 100 | raise CancelOperation 101 | else: 102 | self.__warningExcludeFolderAV(UserConfig().exegol_resources_path) 103 | assert self.__git_resources is not None 104 | if not self.__git_resources.clone(ConstantConfig.EXEGOL_RESOURCES_REPO): 105 | # Error during install, raise error to avoid update process 106 | raise CancelOperation 107 | else: 108 | # User cancel installation, skip update update 109 | raise CancelOperation 110 | 111 | def isExegolResourcesReady(self) -> bool: 112 | """Update Exegol-resources from git (submodule)""" 113 | return self.getResourcesGit(fast_load=True).isAvailable 114 | 115 | @staticmethod 116 | def __warningExcludeFolderAV(directory: Union[str, Path]) -> None: 117 | """Generic procedure to warn the user that not antivirus compatible files will be downloaded and that 118 | the destination folder should be excluded from the scans to avoid any problems""" 119 | logger.warning(f"If you are using an [orange3][g]Anti-Virus[/g][/orange3] on your host, you should exclude the folder {directory} before starting the download.") 120 | while not Confirm(f"Are you ready to start the download?", True): 121 | pass 122 | -------------------------------------------------------------------------------- /exegol/model/MetaImages.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Set, Union, List, Dict, Any 2 | 3 | from docker.models.images import Image 4 | 5 | from exegol.utils.ExeLog import logger 6 | from exegol.utils.WebUtils import WebUtils 7 | 8 | 9 | class MetaImages: 10 | """Meta model to store and organise multi-arch images""" 11 | 12 | def __init__(self, dockerhub_data) -> None: 13 | """Create a MetaImage object to handle multi-arch docker registry images in a single point""" 14 | # Raw data 15 | self.__dockerhub_images: List[Dict[str, Optional[Union[str, int]]]] = dockerhub_data.get('images', {}) 16 | # Attributes 17 | self.name: str = dockerhub_data.get('name', '') 18 | self.multi_arch: bool = len(self.__dockerhub_images) > 1 19 | self.list_arch: Set[str] = set( 20 | [self.parseArch(a) for a in self.__dockerhub_images]) 21 | self.meta_id: Optional[str] = dockerhub_data.get("digest") 22 | if not self.meta_id: 23 | if self.multi_arch: 24 | logger.debug(f"Missing ID for image {self.name}, manual fetching ! May slow down the process..") 25 | self.meta_id = WebUtils.getMetaDigestId(self.name) 26 | else: 27 | # Single arch image dont need virtual meta_id 28 | self.__dockerhub_images[0].get('digest') 29 | self.version: str = self.tagNameParsing(self.name) 30 | self.is_latest: bool = not bool(self.version) # Current image is latest if no version have been found from tag name 31 | # Post-process data 32 | self.__image_arch_match: Set[str] = set() 33 | 34 | @staticmethod 35 | def tagNameParsing(tag_name: str) -> str: 36 | parts = tag_name.split('-') 37 | version = '-'.join(parts[1:]) 38 | # Code for future multi parameter from tag name (e.g. ad-debian-1.2.3) 39 | """ 40 | first_parameter = "" 41 | # Try to detect legacy tag name or new latest name 42 | if len(parts) == 2: 43 | # If there is any '.' in the second part, it's a version format 44 | if "." in parts[1]: 45 | # Legacy version format 46 | version = parts[1] 47 | else: 48 | # Latest arch specific image 49 | first_parameter = parts[1] 50 | elif len(parts) >= 3: 51 | # Arch + version format 52 | first_parameter = parts[1] 53 | # Additional - stored in version 54 | version = '-'.join(parts[2:]) 55 | 56 | return version, first_parameter 57 | """ 58 | return version 59 | 60 | @staticmethod 61 | def parseArch(docker_image: Union[Dict[str, Optional[Union[str, int]]], Image]) -> str: 62 | """Parse and format arch in dockerhub style from registry dict struct. 63 | Return arch in format 'arch/variant'.""" 64 | arch_key = "architecture" 65 | variant_key = "variant" 66 | # Support Docker image struct with specific dict key 67 | if type(docker_image) is Image: 68 | docker_image = docker_image.attrs 69 | arch_key = "Architecture" 70 | variant_key = "Variant" 71 | arch = str(docker_image.get(arch_key, "amd64")) 72 | variant = docker_image.get(variant_key) 73 | if variant: 74 | arch += f"/{variant}" 75 | return arch 76 | 77 | def getDockerhubImageForArch(self, arch: str) -> Optional[dict]: 78 | """Find a docker image corresponding to a specific arch""" 79 | for img in self.__dockerhub_images: 80 | if self.parseArch(img) == arch: 81 | self.__image_arch_match.add(arch) 82 | return img 83 | return None 84 | 85 | def getImagesLeft(self) -> List[dict]: 86 | """Return every image not previously selected.""" 87 | result = [] 88 | for img in self.__dockerhub_images: 89 | if self.parseArch(img) not in self.__image_arch_match: 90 | result.append(img) 91 | return result 92 | 93 | def setVersionSpecific(self, meta_version: 'MetaImages') -> None: 94 | self.version = meta_version.version 95 | 96 | def __str__(self) -> str: 97 | return f"{self.name} ({self.version}) [{self.meta_id}] {self.list_arch}" 98 | 99 | def __repr__(self): 100 | return self.__str__() 101 | -------------------------------------------------------------------------------- /exegol/model/SelectableInterface.py: -------------------------------------------------------------------------------- 1 | class SelectableInterface: 2 | """Generic class used to select objects in the user TUI""" 3 | 4 | def getKey(self) -> str: 5 | """Universal unique key getter""" 6 | raise NotImplementedError 7 | 8 | def __eq__(self, other) -> bool: 9 | """Generic '==' operator overriding matching object key""" 10 | return other == self.getKey() 11 | -------------------------------------------------------------------------------- /exegol/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePorgs/Exegol/3ef46e7fff8b45617dead7c6d5ea83635d609834/exegol/model/__init__.py -------------------------------------------------------------------------------- /exegol/utils/ContainerLogStream.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime, timedelta 3 | from typing import Optional 4 | 5 | from docker.models.containers import Container 6 | 7 | from exegol.utils.ExeLog import logger 8 | 9 | 10 | class ContainerLogStream: 11 | 12 | def __init__(self, container: Container, start_date: Optional[datetime] = None, timeout: int = 5): 13 | # Container to extract logs from 14 | self.__container = container 15 | # Fetch more logs from this datetime 16 | self.__start_date: datetime = datetime.now() if start_date is None else start_date 17 | self.__since_date = self.__start_date 18 | self.__until_date: Optional[datetime] = None 19 | # The data stream is returned from the docker SDK. It can contain multiple line at the same. 20 | self.__data_stream = None 21 | self.__line_buffer = b'' 22 | 23 | # Enable timeout if > 0. Passed timeout_date, the iterator will stop. 24 | self.__enable_timeout = timeout > 0 25 | self.__timeout_date: datetime = self.__since_date + timedelta(seconds=timeout) 26 | 27 | # Hint message flag 28 | self.__tips_sent = False 29 | self.__tips_timedelta = self.__start_date + timedelta(seconds=30) 30 | 31 | def __iter__(self): 32 | return self 33 | 34 | def __next__(self) -> str: 35 | """Get the next line of the stream""" 36 | if self.__until_date is None: 37 | self.__until_date = datetime.now() 38 | while True: 39 | # The data stream is fetch from the docker SDK once empty. 40 | if self.__data_stream is None: 41 | # The 'follow' mode cannot be used because there is no timeout mechanism and will stuck the process forever 42 | self.__data_stream = self.__container.logs(stream=True, follow=False, since=self.__since_date, until=self.__until_date) 43 | assert self.__data_stream is not None 44 | # Parsed the data stream to extract characters and merge them into a line. 45 | for streamed_char in self.__data_stream: 46 | self.__enable_timeout = False # disable timeout if the container is up-to-date and support console logging 47 | # Add new char to the buffer + Unify \r\n to \n 48 | self.__line_buffer += streamed_char.replace(b'\r\n', b'\n') 49 | # When detecting an end of line, the buffer is returned as a single line. 50 | if b'\n' in self.__line_buffer: 51 | lines = self.__line_buffer.split(b'\n') 52 | self.__line_buffer = b'\n'.join(lines[1:]) if len(lines) > 1 else b'' 53 | if len(lines[0]) > 0: 54 | return lines[0].decode('utf-8').strip() 55 | # When the data stream is empty, check if a timeout condition apply 56 | if self.__enable_timeout and self.__until_date >= self.__timeout_date: 57 | logger.debug("Container log stream timed-out") 58 | raise StopIteration 59 | elif not self.__tips_sent and self.__until_date >= self.__tips_timedelta: 60 | self.__tips_sent = True 61 | logger.info("Your start-up sequence takes time, your my-resource setup configuration may be significant.") 62 | logger.info("[orange3][Tips][/orange3] If you want to skip startup update, " 63 | "you can use [green]CTRL+C[/green] and spawn a shell immediately. " 64 | "[blue](Startup sequence will continue in background)[/blue]") 65 | # Prepare the next iteration to fetch next logs 66 | self.__data_stream = None 67 | self.__since_date = self.__until_date 68 | time.sleep(1) # Wait for more logs 69 | self.__until_date = datetime.now() 70 | -------------------------------------------------------------------------------- /exegol/utils/DataFileUtils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | from json import JSONEncoder, JSONDecodeError 5 | from pathlib import Path 6 | from typing import Union, Dict, cast, Optional, Set, Any 7 | 8 | import yaml 9 | import yaml.parser 10 | 11 | from exegol.config.ConstantConfig import ConstantConfig 12 | from exegol.utils.ExeLog import logger 13 | from exegol.utils.FsUtils import mkdir, get_user_id 14 | 15 | 16 | class DataFileUtils: 17 | 18 | class ObjectJSONEncoder(JSONEncoder): 19 | def default(self, o): 20 | result = {} 21 | for key, value in o.__dict__.items(): 22 | if key.startswith("_"): 23 | continue 24 | result[key] = value 25 | return result 26 | 27 | def __init__(self, file_path: Union[Path, str], file_type: str): 28 | """Generic class for datastorage in config file. 29 | :param file_path: can be a Path object or a string. If a string is supplied it will be automatically place inside the default exegol directory. 30 | :param file_type: defined the type of file to create. It can be 'yml' or 'json'. 31 | """ 32 | if type(file_path) is str: 33 | file_path = ConstantConfig.exegol_config_path / file_path 34 | if file_type not in ["yml", "yaml", "json"]: 35 | raise NotImplementedError(f"The file type '{file_type}' is not implemented") 36 | # Config file options 37 | self._file_path: Path = cast(Path, file_path) 38 | self.__file_type: str = file_type 39 | self.__config_upgrade: bool = False 40 | 41 | self._raw_data: Any = None 42 | 43 | # Process 44 | self.__load_file() 45 | 46 | def __load_file(self) -> None: 47 | """ 48 | Function to load the file and the corresponding parameters 49 | :return: 50 | """ 51 | if not self._file_path.parent.is_dir(): 52 | logger.verbose(f"Creating config folder: {self._file_path.parent}") 53 | mkdir(self._file_path.parent) 54 | if not self._file_path.is_file(): 55 | logger.verbose(f"Creating default file: {self._file_path}") 56 | self._create_config_file() 57 | else: 58 | self._parse_config() 59 | if self.__config_upgrade: 60 | logger.verbose("Upgrading config file") 61 | self._create_config_file() 62 | 63 | def _build_file_content(self) -> str: 64 | """ 65 | This fonction build the default file content. Called when the file doesn't exist yet or have been upgrade and need to be updated. 66 | :return: 67 | """ 68 | raise NotImplementedError(f"The '_build_default_file' method hasn't been implemented in the '{self.__class__}' class.") 69 | 70 | def _create_config_file(self) -> None: 71 | """ 72 | Create or overwrite the file content to the default / current value depending on the '_build_default_file' that must be redefined in child class. 73 | :return: 74 | """ 75 | try: 76 | with open(self._file_path, 'w') as file: 77 | file.write(self._build_file_content()) 78 | if sys.platform == "linux" and os.getuid() == 0: 79 | user_uid, user_gid = get_user_id() 80 | os.chown(self._file_path, user_uid, user_gid) 81 | except PermissionError as e: 82 | logger.critical(f"Unable to open the file '{self._file_path}' ({e}). Please fix your file permissions or run exegol with the correct rights.") 83 | except OSError as e: 84 | logger.critical(f"A critical error occurred while interacting with filesystem: [{type(e)}] {e}") 85 | 86 | def _parse_config(self) -> None: 87 | data: Dict = {} 88 | with open(self._file_path, 'r') as file: 89 | try: 90 | if self.__file_type == "yml": 91 | data = yaml.safe_load(file) 92 | elif self.__file_type == "json": 93 | data = json.load(file) 94 | except yaml.parser.ParserError: 95 | logger.error("Error while parsing exegol config file ! Check for syntax error.") 96 | except JSONDecodeError: 97 | logger.error(f"Error while parsing exegol data file {self._file_path} ! Check for syntax error.") 98 | if data is None: 99 | logger.warning(f"Exegol was unable to load the file {self._file_path}. Restoring it to its original state.") 100 | self._create_config_file() 101 | else: 102 | self._raw_data = data 103 | self._process_data() 104 | 105 | def __load_config(self, data: dict, config_name: str, default: Union[bool, str], 106 | choices: Optional[Set[str]] = None) -> Union[bool, str]: 107 | """ 108 | Function to automatically load a data from a dict object. This function can handle limited choices and default value. 109 | If the parameter don't exist,a reset flag will be raised. 110 | :param data: Dict data to retrieve the value from 111 | :param config_name: Key name of the config to find 112 | :param default: Default value is the value hasn't been set yet 113 | :param choices: (Optional) A limit set of acceptable values 114 | :return: This function return the value for the corresponding config_name. 115 | """ 116 | try: 117 | result = data.get(config_name) 118 | if result is None: 119 | logger.debug(f"Config {config_name} has not been found in Exegol '{self._file_path.name}' config file. The file will be upgrade.") 120 | self.__config_upgrade = True 121 | return default 122 | elif choices is not None and result not in choices: 123 | logger.warning(f"The configuration is incorrect! " 124 | f"The user has configured the '{config_name}' parameter with the value '{result}' " 125 | f"which is not one of the allowed options ({', '.join(choices)}). Using default value: {default}.") 126 | return default 127 | return result 128 | except TypeError: 129 | logger.error(f"Error while loading {config_name}! Using default config.") 130 | return default 131 | 132 | def _load_config_bool(self, data: dict, config_name: str, default: bool, 133 | choices: Optional[Set[str]] = None) -> bool: 134 | """ 135 | Function to automatically load a BOOL from a dict object. This function can handle limited choices and default value. 136 | If the parameter don't exist,a reset flag will be raised. 137 | :param data: Dict data to retrieve the value from 138 | :param config_name: Key name of the config to find 139 | :param default: Default value is the value hasn't been set yet 140 | :param choices: (Optional) A limit set of acceptable values 141 | :return: This function return the value for the corresponding config_name 142 | """ 143 | return cast(bool, self.__load_config(data, config_name, default, choices)) 144 | 145 | def _load_config_str(self, data: dict, config_name: str, default: str, choices: Optional[Set[str]] = None) -> str: 146 | """ 147 | Function to automatically load a STR from a dict object. This function can handle limited choices and default value. 148 | If the parameter don't exist,a reset flag will be raised. 149 | :param data: Dict data to retrieve the value from 150 | :param config_name: Key name of the config to find 151 | :param default: Default value is the value hasn't been set yet 152 | :param choices: (Optional) A limit set of acceptable values 153 | :return: This function return the value for the corresponding config_name 154 | """ 155 | return cast(str, self.__load_config(data, config_name, default, choices)) 156 | 157 | def _load_config_path(self, data: dict, config_name: str, default: Path) -> Path: 158 | """ 159 | Function to automatically load a PATH from a dict object. This function can handle limited choices and default value. 160 | If the parameter don't exist,a reset flag will be raised. 161 | :param data: Dict data to retrieve the value from 162 | :param config_name: Key name of the config to find 163 | :param default: Default value is the value hasn't been set yet 164 | :return: This function return the value for the corresponding config_name 165 | """ 166 | try: 167 | result = data.get(config_name) 168 | if result is None: 169 | logger.debug(f"Config {config_name} has not been found in Exegol '{self._file_path.name}' config file. The file will be upgrade.") 170 | self.__config_upgrade = True 171 | return default 172 | return Path(result).expanduser() 173 | except TypeError: 174 | logger.error(f"Error while loading {config_name}! Using default config.") 175 | return default 176 | 177 | def _process_data(self) -> None: 178 | raise NotImplementedError(f"The '_process_data' method hasn't been implemented in the '{self.__class__}' class.") 179 | 180 | def _config_upgrade(self): 181 | pass 182 | -------------------------------------------------------------------------------- /exegol/utils/ExeLog.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from typing import Any, cast 4 | 5 | from rich.console import Console 6 | from rich.logging import RichHandler 7 | 8 | 9 | # Customized logging class 10 | class ExeLog(logging.Logger): 11 | """Project's Logger custom class""" 12 | # New logging level 13 | SUCCESS: int = 25 14 | VERBOSE: int = 15 15 | ADVANCED: int = 13 16 | 17 | @staticmethod 18 | def setVerbosity(verbose: int, quiet: bool = False): 19 | """Set logging level accordingly to the verbose count or with quiet enable.""" 20 | if quiet: 21 | logger.setLevel(logging.CRITICAL) 22 | elif verbose == 1: 23 | logger.setLevel(ExeLog.VERBOSE) 24 | elif verbose == 2: 25 | logger.setLevel(ExeLog.ADVANCED) 26 | elif verbose >= 3: 27 | logger.setLevel(logging.DEBUG) 28 | else: 29 | # Default INFO 30 | logger.setLevel(logging.INFO) 31 | 32 | def debug(self, msg: Any, *args: Any, **kwargs: Any) -> None: 33 | """Change default debug text format with rich color support""" 34 | super(ExeLog, self).debug("{}[D]{} {}".format("[bold yellow3]", "[/bold yellow3]", msg), *args, **kwargs) 35 | 36 | def advanced(self, msg: Any, *args: Any, **kwargs: Any) -> None: 37 | """Add advanced logging method with text format / rich color support""" 38 | if self.isEnabledFor(ExeLog.ADVANCED): 39 | self._log(ExeLog.ADVANCED, 40 | "{}[A]{} {}".format("[bold yellow3]", "[/bold yellow3]", msg), args, **kwargs) 41 | 42 | def verbose(self, msg: Any, *args: Any, **kwargs: Any) -> None: 43 | """Add verbose logging method with text format / rich color support""" 44 | if self.isEnabledFor(ExeLog.VERBOSE): 45 | self._log(ExeLog.VERBOSE, 46 | "{}[V]{} {}".format("[bold blue]", "[/bold blue]", msg), args, **kwargs) 47 | 48 | def raw(self, msg: Any, level=VERBOSE, markup=False, highlight=False, emoji=False, rich_parsing=False) -> None: 49 | """Add raw text logging, used for stream printing.""" 50 | if rich_parsing: 51 | markup = True 52 | highlight = True 53 | emoji = True 54 | if self.isEnabledFor(level): 55 | if type(msg) is bytes: 56 | msg = msg.decode('utf-8', errors="ignore") 57 | # Raw message are print directly to the console bypassing logging system and auto formatting 58 | console.print(msg, end='', markup=markup, highlight=highlight, emoji=emoji) 59 | 60 | def info(self, msg: Any, *args: Any, **kwargs: Any) -> None: 61 | """Change default info text format with rich color support""" 62 | super(ExeLog, self).info("{}[*]{} {}".format("[bold blue]", "[/bold blue]", msg), *args, **kwargs) 63 | 64 | def warning(self, msg: Any, *args: Any, **kwargs: Any) -> None: 65 | """Change default warning text format with rich color support""" 66 | super(ExeLog, self).warning("{}[!]{} {}".format("[bold orange3]", "[/bold orange3]", msg), *args, **kwargs) 67 | 68 | def error(self, msg: Any, *args: Any, **kwargs: Any) -> None: 69 | """Change default error text format with rich color support""" 70 | super(ExeLog, self).error("{}[-]{} {}".format("[bold red]", "[/bold red]", msg), *args, **kwargs) 71 | 72 | def exception(self, msg: Any, *args: Any, **kwargs: Any) -> None: 73 | """Change default exception text format with rich color support""" 74 | super(ExeLog, self).exception("{}[x]{} {}".format("[bold red]", "[/bold red]", msg), *args, **kwargs) 75 | 76 | def critical(self, msg: Any, *args: Any, **kwargs: Any) -> None: 77 | """Change default critical text format with rich color support 78 | Add auto exit.""" 79 | super(ExeLog, self).critical("{}[!]{} {}".format("[bold red]", "[/bold red]", msg), *args, **kwargs) 80 | exit(1) 81 | 82 | def success(self, msg: Any, *args: Any, **kwargs: Any) -> None: 83 | """Add success logging method with text format / rich color support""" 84 | if self.isEnabledFor(ExeLog.SUCCESS): 85 | self._log(ExeLog.SUCCESS, 86 | "{}[+]{} {}".format("[bold green]", "[/bold green]", msg), args, **kwargs) 87 | 88 | def empty_line(self, log_level: int = logging.INFO) -> None: 89 | """Print an empty line.""" 90 | self.raw(os.linesep, level=log_level) 91 | 92 | 93 | # Global rich console object 94 | console: Console = Console() 95 | 96 | # Main logging default config 97 | # Set default Logger class as ExeLog 98 | logging.setLoggerClass(ExeLog) 99 | 100 | # Add new level to the logging config 101 | logging.addLevelName(ExeLog.VERBOSE, "VERBOSE") 102 | logging.addLevelName(ExeLog.SUCCESS, "SUCCESS") 103 | logging.addLevelName(ExeLog.ADVANCED, "ADVANCED") 104 | # Logging setup using RichHandler and minimalist text format 105 | logging.basicConfig( 106 | format="%(message)s", 107 | handlers=[RichHandler(rich_tracebacks=True, 108 | show_time=False, 109 | markup=True, 110 | show_level=False, 111 | show_path=False, 112 | console=console)] 113 | ) 114 | 115 | # Global logger object 116 | logger: ExeLog = cast(ExeLog, logging.getLogger("main")) 117 | # Default log level 118 | logger.setLevel(logging.INFO) 119 | -------------------------------------------------------------------------------- /exegol/utils/FsUtils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | import stat 5 | import subprocess 6 | import sys 7 | from pathlib import Path, PurePath 8 | from typing import Optional, Tuple 9 | 10 | from exegol.config.EnvInfo import EnvInfo 11 | from exegol.utils.ExeLog import logger 12 | 13 | 14 | def parseDockerVolumePath(source: str) -> PurePath: 15 | """Parse docker volume path to find the corresponding host path.""" 16 | # Check if path is from Windows Docker Desktop 17 | matches = re.match(r"^/run/desktop/mnt/host/([a-z])(/.*)$", source, re.IGNORECASE) 18 | if matches: 19 | # Convert Windows Docker-VM style volume path to local OS path 20 | src_path = Path(f"{matches.group(1).upper()}:{matches.group(2)}") 21 | logger.debug(f"Windows style detected : {src_path}") 22 | return src_path 23 | else: 24 | # Remove docker mount path if exist 25 | return PurePath(source.replace('/run/desktop/mnt/host', '')) 26 | 27 | 28 | def resolvPath(path: Path) -> str: 29 | """Resolv a filesystem path depending on the environment. 30 | On WSL, Windows PATH can be resolved using 'wslpath'.""" 31 | if path is None: 32 | return '' 33 | # From WSL, Windows Path must be resolved (try to detect a Windows path with '\') 34 | if EnvInfo.current_platform == "WSL" and '\\' in str(path): 35 | try: 36 | # Resolv Windows path on WSL environment 37 | p = subprocess.Popen(["wslpath", "-a", str(path)], stdout=subprocess.PIPE, stderr=subprocess.PIPE) 38 | output, err = p.communicate() 39 | logger.debug(f"Resolv path input: {path}") 40 | logger.debug(f"Resolv path output: {output!r}") 41 | if err != b'': 42 | # result is returned to STDERR when the translation didn't properly find a match 43 | logger.debug(f"Error on FS path resolution: {err!r}. Input path is probably a linux path.") 44 | else: 45 | return output.decode('utf-8').strip() 46 | except FileNotFoundError: 47 | logger.warning("Missing WSL tools: 'wslpath'. Skipping resolution.") 48 | return str(path) 49 | 50 | 51 | def resolvStrPath(path: Optional[str]) -> str: 52 | """Try to resolv a filesystem path from a string.""" 53 | if path is None: 54 | return '' 55 | return resolvPath(Path(path)) 56 | 57 | 58 | def setGidPermission(root_folder: Path) -> None: 59 | """Set the setgid permission bit to every recursive directory""" 60 | logger.verbose(f"Updating the permissions of {root_folder} (and sub-folders) to allow file sharing between the container and the host user") 61 | logger.debug(f"Adding setgid permission recursively on directories from {root_folder}") 62 | perm_alert = False 63 | # Set permission to root directory 64 | try: 65 | root_folder.chmod(root_folder.stat().st_mode | stat.S_IRWXG | stat.S_ISGID) 66 | except PermissionError: 67 | # Trigger the error only if the permission is not already set 68 | if not root_folder.stat().st_mode & stat.S_ISGID: 69 | logger.warning(f"The permission of this directory ({root_folder}) cannot be automatically changed.") 70 | perm_alert = True 71 | for sub_item in root_folder.rglob('*'): 72 | # Find every subdirectory 73 | try: 74 | if not sub_item.is_dir(): 75 | continue 76 | except PermissionError: 77 | if not sub_item.is_symlink(): 78 | logger.error(f"Permission denied when trying to resolv {str(sub_item)}") 79 | continue 80 | # If the permission is already set, skip 81 | if sub_item.stat().st_mode & stat.S_ISGID: 82 | continue 83 | # Set the permission (g+s) to every child directory 84 | try: 85 | sub_item.chmod(sub_item.stat().st_mode | stat.S_IRWXG | stat.S_ISGID) 86 | except PermissionError: 87 | logger.warning(f"The permission of this directory ({sub_item}) cannot be automatically changed.") 88 | perm_alert = True 89 | if perm_alert: 90 | logger.warning(f"In order to share files between your host and exegol (without changing the permission), you can run [orange3]manually[/orange3] this command from your [red]host[/red]:") 91 | logger.empty_line() 92 | logger.raw(f"sudo chgrp -R $(id -g) {root_folder} && sudo find {root_folder} -type d -exec chmod g+rws {{}} \\;", level=logging.WARNING) 93 | logger.empty_line() 94 | logger.empty_line() 95 | 96 | 97 | def check_sysctl_value(sysctl: str, compare_to: str) -> bool: 98 | """Function to find a sysctl configured value and compare it to a desired value.""" 99 | sysctl_path = "/proc/sys/" + sysctl.replace('.', '/') 100 | try: 101 | with open(sysctl_path, 'r') as conf: 102 | config = conf.read().strip() 103 | logger.debug(f"Checking sysctl value {sysctl}={config} (compare to {compare_to})") 104 | return conf.read().strip() == compare_to 105 | except FileNotFoundError: 106 | logger.debug(f"Sysctl file {sysctl} not found!") 107 | except PermissionError: 108 | logger.debug(f"Unable to read sysctl {sysctl} permission!") 109 | return False 110 | 111 | 112 | def get_user_id() -> Tuple[int, int]: 113 | """On linux system, retrieve the original user id when using SUDO.""" 114 | if sys.platform == "win32": 115 | raise SystemError 116 | user_uid_raw = os.getenv("SUDO_UID") 117 | if user_uid_raw is None: 118 | user_uid = os.getuid() 119 | else: 120 | user_uid = int(user_uid_raw) 121 | user_gid_raw = os.getenv("SUDO_GID") 122 | if user_gid_raw is None: 123 | user_gid = os.getgid() 124 | else: 125 | user_gid = int(user_gid_raw) 126 | return user_uid, user_gid 127 | 128 | 129 | def mkdir(path) -> None: 130 | """Function to recursively create a directory and setting the right user and group id to allow host user access.""" 131 | try: 132 | path.mkdir(parents=False, exist_ok=False) 133 | if sys.platform == "linux" and os.getuid() == 0: 134 | user_uid, user_gid = get_user_id() 135 | os.chown(path, user_uid, user_gid) 136 | except FileExistsError: 137 | # The directory already exist, this setup can be skipped 138 | pass 139 | except FileNotFoundError: 140 | # Create parent directory first 141 | mkdir(path.parent) 142 | # Then create the targeted directory 143 | mkdir(path) 144 | -------------------------------------------------------------------------------- /exegol/utils/GitUtils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | from typing import Optional, List 5 | 6 | from git import Commit 7 | from git.exc import GitCommandError, RepositoryDirtyError 8 | from rich.progress import TextColumn, BarColumn 9 | 10 | from exegol.config.ConstantConfig import ConstantConfig 11 | from exegol.config.EnvInfo import EnvInfo 12 | from exegol.console.MetaGitProgress import MetaGitProgress, clone_update_progress, SubmoduleUpdateProgress 13 | from exegol.console.cli.ParametersManager import ParametersManager 14 | from exegol.utils.ExeLog import logger, console 15 | 16 | 17 | # SDK Documentation : https://gitpython.readthedocs.io/en/stable/index.html 18 | 19 | class GitUtils: 20 | """Utility class between exegol and the Git SDK""" 21 | 22 | def __init__(self, 23 | path: Optional[Path] = None, 24 | name: str = "wrapper", 25 | subject: str = "source code", 26 | skip_submodule_update: bool = False): 27 | """Init git local repository object / SDK""" 28 | if path is None: 29 | path = ConstantConfig.src_root_path_obj 30 | self.isAvailable = False 31 | self.__is_submodule = False 32 | self.__git_disable = False 33 | self.__repo_path = path 34 | self.__git_name: str = name 35 | self.__git_subject: str = subject 36 | abort_loading = False 37 | # Check if .git directory exist 38 | try: 39 | test_git_dir = self.__repo_path / '.git' 40 | if test_git_dir.is_file(): 41 | logger.debug("Git submodule repository detected") 42 | self.__is_submodule = True 43 | elif not test_git_dir.is_dir(): 44 | raise ReferenceError 45 | elif sys.platform == "win32": 46 | # Skip next platform specific code (temp fix for mypy static code analysis) 47 | pass 48 | elif not EnvInfo.is_windows_shell and os.getuid() != 0 and test_git_dir.lstat().st_uid != os.getuid(): 49 | raise PermissionError(test_git_dir.owner()) 50 | except ReferenceError: 51 | if self.__git_name == "wrapper": 52 | logger.warning("Exegol has [red]not[/red] been installed via git clone. Skipping wrapper auto-update operation.") 53 | if ConstantConfig.pipx_installed: 54 | logger.info("If you have installed Exegol with pipx, check for an update with the command " 55 | "[green]pipx upgrade exegol[/green]") 56 | elif ConstantConfig.pip_installed: 57 | logger.info("If you have installed Exegol with pip, check for an update with the command " 58 | "[green]pip3 install exegol --upgrade[/green]") 59 | abort_loading = True 60 | except PermissionError as e: 61 | logger.error(f"The repository {self.__git_name} has been cloned as [red]{e.args[0]}[/red].") 62 | logger.error("The current user does not have the necessary rights to perform the self-update operations.") 63 | logger.error("Please reinstall exegol (with git clone) without sudo.") 64 | abort_loading = True 65 | # locally import git in case git is not installed of the system 66 | try: 67 | from git import Repo, Remote, InvalidGitRepositoryError, FetchInfo 68 | except ModuleNotFoundError: 69 | self.__git_disable = True 70 | logger.warning("Git module is not installed. Python module 'GitPython' is missing, please install it with pip.") 71 | return 72 | except ImportError: 73 | self.__git_disable = True 74 | logger.error("Unable to find git tool locally. Skipping git operations.") 75 | return 76 | self.__gitRepo: Optional[Repo] = None 77 | self.__gitRemote: Optional[Remote] = None 78 | self.__fetchBranchInfo: Optional[FetchInfo] = None 79 | 80 | if abort_loading: 81 | return 82 | logger.debug(f"Loading git at {self.__repo_path}") 83 | try: 84 | self.__gitRepo = Repo(self.__repo_path) 85 | logger.debug(f"Repo path: {self.__gitRepo.git_dir}") 86 | self.__init_repo(skip_submodule_update) 87 | except InvalidGitRepositoryError as err: 88 | logger.verbose(err) 89 | logger.warning("Error while loading local git repository. Skipping all git operation.") 90 | 91 | def __init_repo(self, skip_submodule_update: bool = False) -> None: 92 | self.isAvailable = True 93 | assert self.__gitRepo is not None 94 | logger.debug("Git repository successfully loaded") 95 | if len(self.__gitRepo.remotes) > 0: 96 | self.__gitRemote = self.__gitRepo.remotes['origin'] 97 | else: 98 | logger.warning("No remote git origin found on repository") 99 | logger.debug(self.__gitRepo.remotes) 100 | if not skip_submodule_update: 101 | self.__initSubmodules() 102 | 103 | def clone(self, repo_url: str, optimize_disk_space: bool = True) -> bool: 104 | if ParametersManager().offline_mode: 105 | logger.error("It's not possible to clone a repository in offline mode ...") 106 | return False 107 | if self.isAvailable: 108 | logger.warning(f"The {self.getName()} repo is already cloned.") 109 | return False 110 | # locally import git in case git is not installed of the system 111 | try: 112 | from git import Repo, Remote, InvalidGitRepositoryError, FetchInfo 113 | except ModuleNotFoundError: 114 | logger.debug("Git module is not installed.") 115 | return False 116 | except ImportError: 117 | logger.error(f"Unable to find git on your machine. The {self.getName()} repository cannot be cloned.") 118 | logger.warning("Please install git to support this feature.") 119 | return False 120 | custom_options = [] 121 | if optimize_disk_space: 122 | custom_options.append('--depth=1') 123 | from git import GitCommandError 124 | try: 125 | with MetaGitProgress(TextColumn("{task.description}", justify="left"), 126 | BarColumn(bar_width=None), 127 | "•", 128 | "[progress.percentage]{task.percentage:>3.1f}%", 129 | "[bold]{task.completed}/{task.total}[/bold]") as progress: 130 | progress.add_task(f"[bold red]Cloning {self.getName()} git repository", start=True) 131 | self.__gitRepo = Repo.clone_from(repo_url, str(self.__repo_path), multi_options=custom_options, progress=clone_update_progress) 132 | progress.remove_task(progress.tasks[0].id) 133 | logger.success(f"The Git repository {self.getName()} was successfully cloned!") 134 | except GitCommandError as e: 135 | # GitPython user \n only 136 | error = GitUtils.formatStderr(e.stderr) 137 | logger.error(f"Unable to clone the git repository. {error}") 138 | return False 139 | self.__init_repo() 140 | return True 141 | 142 | def getCurrentBranch(self) -> Optional[str]: 143 | """Get current git branch name""" 144 | if not self.isAvailable: 145 | return None 146 | assert self.__gitRepo is not None 147 | try: 148 | return str(self.__gitRepo.active_branch) 149 | except TypeError: 150 | logger.debug("Git HEAD is detached, cant find the current branch.") 151 | return None 152 | except ValueError: 153 | logger.error(f"Unable to find current git branch in the {self.__git_name} repository. Check the path in the .git file from {self.__repo_path / '.git'}") 154 | return None 155 | 156 | def listBranch(self) -> List[str]: 157 | """Return a list of str of all remote git branch available""" 158 | assert self.isAvailable 159 | assert not ParametersManager().offline_mode 160 | result: List[str] = [] 161 | if self.__gitRemote is None: 162 | return result 163 | for branch in self.__gitRemote.fetch(): 164 | branch_parts = branch.name.split('/') 165 | if len(branch_parts) < 2: 166 | logger.warning(f"Branch name is not correct: {branch.name}") 167 | result.append(branch.name) 168 | else: 169 | result.append('/'.join(branch_parts[1:])) 170 | return result 171 | 172 | def safeCheck(self) -> bool: 173 | """Check the status of the local git repository, 174 | if there is pending change it is not safe to apply some operations""" 175 | assert self.isAvailable 176 | if self.__gitRepo is None or self.__gitRemote is None: 177 | return False 178 | # Submodule changes must be ignored to update the submodules sources independently of the wrapper 179 | is_dirty = self.__gitRepo.is_dirty(submodules=False) 180 | if is_dirty: 181 | logger.warning("Local git have unsaved change. Skipping source update.") 182 | return not is_dirty 183 | 184 | def isUpToDate(self, branch: Optional[str] = None) -> bool: 185 | """Check if the local git repository is up-to-date. 186 | This method compare the last commit local and the ancestor.""" 187 | assert self.isAvailable 188 | assert not ParametersManager().offline_mode 189 | if branch is None: 190 | branch = self.getCurrentBranch() 191 | if branch is None: 192 | logger.warning("No branch is currently attached to the git repository. The up-to-date status cannot be checked.") 193 | return False 194 | assert self.__gitRepo is not None 195 | assert self.__gitRemote is not None 196 | # Get last local commit 197 | current_commit = self.get_current_commit() 198 | # Get last remote commit 199 | if not self.__fetch_update(branch): 200 | return True 201 | 202 | assert self.__fetchBranchInfo is not None 203 | 204 | logger.debug(f"Fetch flags : {self.__fetchBranchInfo.flags}") 205 | logger.debug(f"Fetch note : {self.__fetchBranchInfo.note}") 206 | logger.debug(f"Fetch old commit : {self.__fetchBranchInfo.old_commit}") 207 | logger.debug(f"Fetch remote path : {self.__fetchBranchInfo.remote_ref_path}") 208 | from git import FetchInfo 209 | # Bit check to detect flags info 210 | if self.__fetchBranchInfo.flags & FetchInfo.HEAD_UPTODATE != 0: 211 | logger.debug("HEAD UP TO DATE flag detected") 212 | if self.__fetchBranchInfo.flags & FetchInfo.FAST_FORWARD != 0: 213 | logger.debug("FAST FORWARD flag detected") 214 | if self.__fetchBranchInfo.flags & FetchInfo.ERROR != 0: 215 | logger.debug("ERROR flag detected") 216 | if self.__fetchBranchInfo.flags & FetchInfo.FORCED_UPDATE != 0: 217 | logger.debug("FORCED_UPDATE flag detected") 218 | if self.__fetchBranchInfo.flags & FetchInfo.REJECTED != 0: 219 | logger.debug("REJECTED flag detected") 220 | if self.__fetchBranchInfo.flags & FetchInfo.NEW_TAG != 0: 221 | logger.debug("NEW TAG flag detected") 222 | 223 | remote_commit = self.get_latest_commit() 224 | assert remote_commit is not None 225 | # Check if remote_commit is an ancestor of the last local commit (check if there is local commit ahead) 226 | return self.__gitRepo.is_ancestor(remote_commit, current_commit) 227 | 228 | def __fetch_update(self, branch: Optional[str] = None) -> bool: 229 | """Fetch latest update from remote""" 230 | if self.__gitRemote is None: 231 | return False 232 | try: 233 | fetch_result = self.__gitRemote.fetch() 234 | except GitCommandError: 235 | logger.warning("Unable to fetch information from remote git repository, do you have internet ?") 236 | return False 237 | if branch is None: 238 | branch = self.getCurrentBranch() 239 | try: 240 | self.__fetchBranchInfo = fetch_result[f'{self.__gitRemote}/{branch}'] 241 | except IndexError: 242 | logger.warning("The selected branch is local and cannot be updated.") 243 | return False 244 | return True 245 | 246 | def get_current_commit(self) -> Commit: 247 | """Fetch current commit id on the current branch.""" 248 | assert self.isAvailable 249 | assert self.__gitRepo is not None 250 | branch = self.getCurrentBranch() 251 | if branch is None: 252 | branch = "master" 253 | # Get last local commit 254 | return self.__gitRepo.heads[branch].commit 255 | 256 | def get_latest_commit(self) -> Optional[Commit]: 257 | """Fetch latest remote commit id on the current branch.""" 258 | assert self.isAvailable 259 | assert not ParametersManager().offline_mode 260 | if self.__fetchBranchInfo is None: 261 | if not self.__fetch_update(): 262 | logger.debug("The latest commit cannot be retrieved.") 263 | return None 264 | assert self.__fetchBranchInfo is not None 265 | return self.__fetchBranchInfo.commit 266 | 267 | def update(self) -> bool: 268 | """Update local git repository within current branch""" 269 | assert self.isAvailable 270 | assert not ParametersManager().offline_mode 271 | if not self.safeCheck(): 272 | return False 273 | # Check if the git branch status is not detached 274 | if self.getCurrentBranch() is None: 275 | return False 276 | if self.isUpToDate(): 277 | logger.info(f"Git branch [green]{self.getCurrentBranch()}[/green] is already up-to-date.") 278 | return False 279 | if self.__gitRemote is not None: 280 | logger.info(f"Using branch [green]{self.getCurrentBranch()}[/green] on {self.getName()} repository") 281 | with console.status(f"Updating git [green]{self.getName()}[/green]", spinner_style="blue"): 282 | self.__gitRemote.pull(refspec=self.getCurrentBranch()) 283 | logger.success("Git successfully updated") 284 | return True 285 | return False 286 | 287 | def __initSubmodules(self) -> None: 288 | """Init (and update git object not source code) git sub repositories (only depth=1)""" 289 | if ParametersManager().offline_mode: 290 | logger.error("It's not possible to update any submodule in offline mode ...") 291 | return 292 | logger.verbose(f"Git {self.getName()} init submodules") 293 | # These modules are init / updated manually 294 | blacklist_heavy_modules = ["exegol-resources", "exegol-images"] 295 | if self.__gitRepo is None: 296 | return 297 | with console.status(f"Initialization of git submodules", spinner_style="blue") as s: 298 | try: 299 | submodules = self.__gitRepo.iter_submodules() 300 | except ValueError: 301 | logger.error(f"Unable to find any git submodule from '{self.getName()}' repository. Check the path in the file {self.__repo_path / '.git'}") 302 | return 303 | for current_sub in submodules: 304 | logger.debug(f"Loading repo submodules: {current_sub}") 305 | # Submodule update are skipped if blacklisted or if the depth limit is set 306 | if current_sub.name in blacklist_heavy_modules: 307 | continue 308 | s.update(status=f"Downloading git submodules [green]{current_sub.name}[/green]") 309 | from git.exc import GitCommandError 310 | try: 311 | # TODO add TUI with progress 312 | current_sub.update(recursive=True) 313 | except GitCommandError as e: 314 | error = GitUtils.formatStderr(e.stderr) 315 | logger.debug(f"Unable tu update git submodule {current_sub.name}: {e}") 316 | if "unable to access" in error: 317 | logger.error("You don't have internet to update git submodule. Skipping operation.") 318 | else: 319 | logger.error("Unable to update git submodule. Skipping operation.") 320 | logger.error(error) 321 | except ValueError: 322 | logger.error(f"Unable to update git submodule '{current_sub.name}'. Check the path in the file '{Path(current_sub.path) / '.git'}'") 323 | except RepositoryDirtyError as e: 324 | logger.debug(e) 325 | logger.error(f"Sub-repository {current_sub.name} have uncommitted local changes. Unable to automatically update this repository.") 326 | 327 | def submoduleSourceUpdate(self, name: str) -> bool: 328 | """Update source code from the 'name' git submodule""" 329 | assert not ParametersManager().offline_mode 330 | if not self.isAvailable: 331 | return False 332 | assert self.__gitRepo is not None 333 | try: 334 | submodule = self.__gitRepo.submodule(name) 335 | except ValueError: 336 | logger.debug(f"Git submodule '{name}' not found.") 337 | return False 338 | from git.exc import RepositoryDirtyError 339 | try: 340 | from git.exc import GitCommandError 341 | try: 342 | with MetaGitProgress(TextColumn("{task.description}", justify="left"), 343 | BarColumn(bar_width=None), 344 | "•", 345 | "[progress.percentage]{task.percentage:>3.1f}%", 346 | "[bold]{task.completed}/{task.total}[/bold]") as progress: 347 | progress.add_task(f"[bold red]Downloading submodule [green]{name}[/green]", start=True) 348 | submodule.update(to_latest_revision=True, progress=SubmoduleUpdateProgress()) 349 | progress.remove_task(progress.tasks[0].id) 350 | except GitCommandError as e: 351 | logger.debug(f"Unable tu update git submodule {name}: {e}") 352 | if "unable to access" in e.stderr: 353 | logger.error("You don't have internet to update git submodule. Skipping operation.") 354 | else: 355 | logger.error("Unable to update git submodule. Skipping operation.") 356 | logger.error(e.stderr) 357 | return False 358 | logger.success(f"Submodule [green]{name}[/green] successfully updated.") 359 | return True 360 | except RepositoryDirtyError: 361 | logger.warning(f"Submodule {name} cannot be updated automatically as long as there are local modifications.") 362 | logger.error("Aborting git submodule update.") 363 | logger.empty_line() 364 | return False 365 | 366 | def checkout(self, branch: str) -> bool: 367 | """Change local git branch""" 368 | assert self.isAvailable 369 | if not self.safeCheck(): 370 | return False 371 | if branch == self.getCurrentBranch(): 372 | logger.warning(f"Branch '{branch}' is already the current branch") 373 | return False 374 | assert self.__gitRepo is not None 375 | from git.exc import GitCommandError 376 | try: 377 | # If git local branch didn't exist, change HEAD to the origin branch and create a new local branch 378 | if branch not in self.__gitRepo.heads: 379 | self.__gitRepo.references['origin/' + branch].checkout() 380 | self.__gitRepo.create_head(branch) 381 | self.__gitRepo.heads[branch].checkout() 382 | except GitCommandError as e: 383 | logger.error("Unable to checkout to the selected branch. Skipping operation.") 384 | logger.debug(e) 385 | return False 386 | except IndexError as e: 387 | logger.error("Unable to find the selected branch. Skipping operation.") 388 | logger.debug(e) 389 | return False 390 | logger.success(f"Git successfully checkout to '{branch}'") 391 | return True 392 | 393 | def getTextStatus(self) -> str: 394 | """Get text status from git object for rich print.""" 395 | if self.isAvailable: 396 | from git.exc import GitCommandError 397 | try: 398 | if self.isUpToDate(): 399 | result = "[green]Up to date[/green]" 400 | else: 401 | result = "[orange3]Update available[/orange3]" 402 | except GitCommandError: 403 | # Offline error catch 404 | result = "[green]Installed[/green] [bright_black](offline)[/bright_black]" 405 | else: 406 | if self.__git_disable: 407 | result = "[red]Missing dependencies[/red]" 408 | elif self.__git_name == ["wrapper", "images"] and \ 409 | (ConstantConfig.pip_installed or not ConstantConfig.git_source_installation): 410 | result = "[bright_black]Auto-update not supported[/bright_black]" 411 | else: 412 | result = "[bright_black]Not installed[/bright_black]" 413 | return result 414 | 415 | def getName(self) -> str: 416 | """Git name getter""" 417 | return self.__git_name 418 | 419 | def getSubject(self) -> str: 420 | """Git subject getter""" 421 | return self.__git_subject 422 | 423 | def isSubModule(self) -> bool: 424 | """Git submodule status getter""" 425 | return self.__is_submodule 426 | 427 | @classmethod 428 | def formatStderr(cls, stderr) -> str: 429 | return stderr.replace('\n', '').replace('stderr:', '').strip().strip("'") 430 | 431 | def __repr__(self) -> str: 432 | """Developer debug object representation""" 433 | return f"GitUtils '{self.__git_name}': {'Active' if self.isAvailable else 'Disable'}" 434 | -------------------------------------------------------------------------------- /exegol/utils/MetaSingleton.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | 4 | # Generic singleton class 5 | class MetaSingleton(type): 6 | """Metaclass to create a singleton class""" 7 | __instances: Dict[type, object] = {} 8 | 9 | def __call__(cls, *args, **kwargs) -> object: 10 | """Redirects each call to the current class to the corresponding single instance""" 11 | if cls not in MetaSingleton.__instances: 12 | # If the instance does not already exist, it is created 13 | MetaSingleton.__instances[cls] = super(MetaSingleton, cls).__call__(*args, **kwargs) 14 | # Return the desired object 15 | return MetaSingleton.__instances[cls] 16 | -------------------------------------------------------------------------------- /exegol/utils/WebUtils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import time 4 | import os 5 | from typing import Any, Optional, Dict 6 | 7 | import requests 8 | from requests import Response 9 | 10 | from exegol.console.cli.ParametersManager import ParametersManager 11 | from exegol.exceptions.ExegolExceptions import CancelOperation 12 | from exegol.config.ConstantConfig import ConstantConfig 13 | from exegol.utils.ExeLog import logger 14 | 15 | 16 | class WebUtils: 17 | __registry_token: Optional[str] = None 18 | 19 | @classmethod 20 | def __getGuestToken(cls, action: str = "pull", service: str = "registry.docker.io") -> Optional[str]: 21 | """Generate a guest token for Registry service""" 22 | url = f"https://auth.docker.io/token?scope=repository:{ConstantConfig.IMAGE_NAME}:{action}&service={service}" 23 | response = cls.runJsonRequest(url, service_name="Docker Auth") 24 | if response is not None: 25 | return response.get("access_token") 26 | logger.error("Unable to authenticate to docker as anonymous") 27 | logger.debug(response) 28 | # If token cannot be retrieved, operation must be cancelled 29 | raise CancelOperation 30 | 31 | @classmethod 32 | def __generateLoginToken(cls) -> None: 33 | """Generate an auth token that will be used on future API request""" 34 | # Currently, only support for guest token 35 | cls.__registry_token = cls.__getGuestToken() 36 | 37 | @classmethod 38 | def __getRegistryToken(cls) -> str: 39 | """Registry auth token getter""" 40 | if cls.__registry_token is None: 41 | cls.__generateLoginToken() 42 | assert cls.__registry_token is not None 43 | return cls.__registry_token 44 | 45 | @classmethod 46 | def getLatestWrapperRelease(cls) -> str: 47 | """Fetch from GitHub release the latest Exegol wrapper version""" 48 | if ParametersManager().offline_mode: 49 | raise CancelOperation 50 | url: str = f"https://api.github.com/repos/{ConstantConfig.GITHUB_REPO}/releases/latest" 51 | github_response = cls.runJsonRequest(url, "Github") 52 | if github_response is None: 53 | raise CancelOperation 54 | latest_tag = github_response.get("tag_name") 55 | if latest_tag is None: 56 | logger.warning("Unable to parse the latest Exegol wrapper version from github API.") 57 | logger.debug(github_response) 58 | return latest_tag 59 | 60 | @classmethod 61 | def getMetaDigestId(cls, tag: str) -> Optional[str]: 62 | """Get Virtual digest id of a specific image tag from docker registry""" 63 | if ParametersManager().offline_mode: 64 | return None 65 | try: 66 | token = cls.__getRegistryToken() 67 | except CancelOperation: 68 | return None 69 | manifest_headers = {"Accept": "application/vnd.docker.distribution.manifest.list.v2+json", "Authorization": f"Bearer {token}"} 70 | # Query Docker registry API on manifest endpoint using tag name 71 | url = f"https://{ConstantConfig.DOCKER_REGISTRY}/v2/{ConstantConfig.IMAGE_NAME}/manifests/{tag}" 72 | response = cls.__runRequest(url, service_name="Docker Registry", headers=manifest_headers, method="HEAD") 73 | digest_id: Optional[str] = None 74 | if response is not None: 75 | digest_id = response.headers.get("docker-content-digest") 76 | if digest_id is None: 77 | digest_id = response.headers.get("etag") 78 | return digest_id 79 | 80 | @classmethod 81 | def getRemoteVersion(cls, tag: str) -> Optional[str]: 82 | """Get image version of a specific image tag from docker registry.""" 83 | if ParametersManager().offline_mode: 84 | return None 85 | try: 86 | token = cls.__getRegistryToken() 87 | except CancelOperation: 88 | return None 89 | # In order to access the metadata of the image, the v1 manifest must be use 90 | manifest_headers = {"Accept": "application/vnd.docker.distribution.manifest.v1+json", "Authorization": f"Bearer {token}"} 91 | # Query Docker registry API on manifest endpoint using tag name 92 | url = f"https://{ConstantConfig.DOCKER_REGISTRY}/v2/{ConstantConfig.IMAGE_NAME}/manifests/{tag}" 93 | response = cls.__runRequest(url, service_name="Docker Registry", headers=manifest_headers, method="GET") 94 | version: Optional[str] = None 95 | if response is not None and response.status_code == 200: 96 | data = json.loads(response.content.decode("utf-8")) 97 | received_media_type = data.get("mediaType") 98 | if received_media_type == "application/vnd.docker.distribution.manifest.v1+json": 99 | # Get image version from legacy v1 manifest (faster) 100 | # Parse metadata of the current image from v1 schema 101 | metadata = json.loads(data.get("history", [])[0]['v1Compatibility']) 102 | # Find version label and extract data 103 | version = metadata.get("config", {}).get("Labels", {}).get("org.exegol.version", "") 104 | 105 | # Convert image list to a specific image 106 | elif received_media_type == "application/vnd.docker.distribution.manifest.list.v2+json": 107 | # Get image version from v2 manifest list (slower) 108 | # Retrieve image digest id from manifest image list 109 | manifest = data.get("manifests") 110 | # Get first image manifest 111 | # Handle application/vnd.docker.distribution.manifest.list.v2+json spec 112 | if type(manifest) is list and len(manifest) > 0: 113 | # Get Image digest 114 | first_digest = manifest[0].get("digest") 115 | # Retrieve specific image detail from first image digest (architecture not sensitive) 116 | manifest_headers["Accept"] = "application/vnd.docker.distribution.manifest.v2+json" 117 | url = f"https://{ConstantConfig.DOCKER_REGISTRY}/v2/{ConstantConfig.IMAGE_NAME}/manifests/{first_digest}" 118 | response = cls.__runRequest(url, service_name="Docker Registry", headers=manifest_headers, method="GET") 119 | if response is not None and response.status_code == 200: 120 | data = json.loads(response.content.decode("utf-8")) 121 | # Update received media type to ba handle later 122 | received_media_type = data.get("mediaType") 123 | # Try to extract version tag from a specific image 124 | if received_media_type == "application/vnd.docker.distribution.manifest.v2+json": 125 | # Get image version from v2 manifest (slower) 126 | # Retrieve config detail from config digest 127 | config_digest: Optional[str] = data.get("config", {}).get('digest') 128 | if config_digest is not None: 129 | manifest_headers["Accept"] = "application/json" 130 | url = f"https://{ConstantConfig.DOCKER_REGISTRY}/v2/{ConstantConfig.IMAGE_NAME}/blobs/{config_digest}" 131 | response = cls.__runRequest(url, service_name="Docker Registry", headers=manifest_headers, method="GET") 132 | if response is not None and response.status_code == 200: 133 | data = json.loads(response.content.decode("utf-8")) 134 | # Find version label and extract data 135 | version = data.get("config", {}).get("Labels", {}).get("org.exegol.version") 136 | else: 137 | logger.debug(f"WARNING: Docker API not supported: {received_media_type}") 138 | return version 139 | 140 | @classmethod 141 | def runJsonRequest(cls, url: str, service_name: str, headers: Optional[Dict] = None, method: str = "GET", data: Any = None, retry_count: int = 2) -> Any: 142 | """Fetch a web page from url and parse the result as json.""" 143 | if ParametersManager().offline_mode: 144 | return None 145 | data = cls.__runRequest(url, service_name, headers, method, data, retry_count) 146 | if data is not None and data.status_code == 200: 147 | data = json.loads(data.content.decode("utf-8")) 148 | elif data is not None: 149 | logger.error(f"Error during web request to {service_name} ({data.status_code}) on {url}") 150 | if data.status_code == 404 and service_name == "Dockerhub": 151 | logger.info("The registry is not accessible, if it is a private repository make sure you are authenticated prior with docker login.") 152 | data = None 153 | return data 154 | 155 | @classmethod 156 | def __runRequest(cls, url: str, service_name: str, headers: Optional[Dict] = None, method: str = "GET", data: Any = None, retry_count: int = 2) -> Optional[Response]: 157 | """Fetch a web page from url and catch / log different use cases. 158 | Url: web page to quest 159 | Service_name: service display name for logging purpose 160 | Retry_count: number of retry allowed.""" 161 | # In case of API timeout, allow retrying 1 more time 162 | for i in range(retry_count): 163 | try: 164 | try: 165 | proxies = {} 166 | http_proxy = os.environ.get('HTTP_PROXY') or os.environ.get('http_proxy') 167 | if http_proxy: 168 | proxies['http'] = http_proxy 169 | https_proxy = os.environ.get('HTTPS_PROXY') or os.environ.get('https_proxy') 170 | if https_proxy: 171 | proxies['https'] = https_proxy 172 | no_proxy = os.environ.get('NO_PROXY') or os.environ.get('no_proxy') 173 | if no_proxy: 174 | proxies['no_proxy'] = no_proxy 175 | logger.debug(f"Fetching information from {url}") 176 | response = requests.request(method=method, url=url, timeout=(10, 20), verify=ParametersManager().verify, headers=headers, data=data, proxies=proxies if len(proxies) > 0 else None) 177 | return response 178 | except requests.exceptions.HTTPError as e: 179 | if e.response is not None: 180 | logger.error(f"Response error: {e.response.content.decode('utf-8')}") 181 | else: 182 | logger.error(f"Response error: {e}") 183 | except requests.exceptions.ConnectionError as err: 184 | logger.debug(f"Error: {err}") 185 | error_re = re.search(r"\[Errno [-\d]+]\s?([^']*)('\))+\)*", str(err)) 186 | error_msg = "" 187 | if error_re: 188 | error_msg = f" ({error_re.group(1)})" 189 | logger.error(f"Connection Error: you probably have no internet.{error_msg}") 190 | # Switch to offline mode 191 | ParametersManager().offline_mode = True 192 | except requests.exceptions.RequestException as err: 193 | logger.error(f"Unknown connection error: {err}") 194 | return None 195 | except requests.exceptions.ReadTimeout: 196 | logger.verbose(f"{service_name} time out, retrying ({i + 1}/{retry_count}) ...") 197 | time.sleep(1) 198 | logger.error(f"[green]{service_name}[/green] request has [red]timed out[/red]. Do you have a slow internet connection, or is the remote service slow/down? Retry later.") 199 | return None 200 | -------------------------------------------------------------------------------- /exegol/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePorgs/Exegol/3ef46e7fff8b45617dead7c6d5ea83635d609834/exegol/utils/__init__.py -------------------------------------------------------------------------------- /exegol/utils/argParse.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from logging import CRITICAL 3 | from typing import Optional, List, Union, Dict, cast 4 | 5 | import argcomplete 6 | 7 | from exegol.console.cli.actions.Command import Command, Option 8 | from exegol.utils.ExeLog import logger 9 | 10 | 11 | class ExegolArgParse(argparse.ArgumentParser): 12 | """Overloading of the main parsing (argparse.ArgumentParser) class""" 13 | 14 | # Using Exelog to print built-in parser message 15 | def _print_message(self, message: str, file=None) -> None: 16 | if message: 17 | logger.raw(message, level=CRITICAL, rich_parsing=True) 18 | 19 | 20 | class Parser: 21 | """Custom Exegol CLI Parser. Main controller of argument building and parsing.""" 22 | 23 | __description = """This Python script is a wrapper for Exegol. It can be used to easily manage Exegol on your machine. 24 | 25 | [bold magenta]Exegol documentation:[/bold magenta] [underline magenta]https://exegol.rtfd.io[/underline magenta]""" 26 | __formatter_class = argparse.RawTextHelpFormatter 27 | 28 | def __init__(self, actions: List[Command]): 29 | """Custom parser creation""" 30 | # Defines every actions available 31 | self.__actions: List[Command] = actions 32 | # Create & init root parser 33 | self.__root_parser: ExegolArgParse 34 | self.__init_parser() 35 | # Configure root options 36 | # (WARNING: these parameters are duplicate with every sub-parse, cannot use positional args here) 37 | self.__set_options(self.__root_parser, Command()) # Add global arguments from Command to the root parser 38 | # Create & fill sub-parser 39 | self.subParser = self.__root_parser.add_subparsers(help="Description of the actions") 40 | self.__set_action_parser() 41 | 42 | def __init_parser(self) -> None: 43 | """Root parser creation""" 44 | 45 | self.__root_parser = ExegolArgParse( 46 | description=self.__description, 47 | epilog=Command().formatEpilog(), 48 | formatter_class=self.__formatter_class, 49 | ) 50 | 51 | def __set_action_parser(self) -> None: 52 | """Create sub-parser for each action and configure it""" 53 | self.__root_parser._positionals.title = "[green]Required arguments[/green]" 54 | for action in self.__actions: 55 | # Each action has a dedicated sub-parser with different options 56 | # the 'help' description of the current action is retrieved 57 | # from the comment of the corresponding action class 58 | if action.__doc__ is None: 59 | action.__doc__ = "Unknown action" 60 | sub_parser = self.subParser.add_parser(action.name, help=action.__doc__, 61 | description=action.__doc__ + f"""\n 62 | [bold magenta]Exegol documentation:[/bold magenta] [underline magenta]https://exegol.rtfd.io/en/latest/exegol-wrapper/{action.name}.html[/underline magenta]""", 63 | epilog=action.formatEpilog(), 64 | formatter_class=self.__formatter_class) 65 | sub_parser.set_defaults(action=action) 66 | self.__set_options(sub_parser, target=action) 67 | 68 | def __set_options(self, sub_parser: argparse.ArgumentParser, target: Optional[Command] = None) -> None: 69 | """Add different groups and parameters/options in the current sub_parser""" 70 | global_set = False # Only one group can be global at the time 71 | # Load actions to be processed (default: every action from cls) 72 | actions_list = [target] if target else self.__actions 73 | for action in actions_list: 74 | # On each action, fetch every group to be processed 75 | for argument_group in action.groupArgs: 76 | group_parser: argparse._ActionsContainer 77 | if argument_group.is_global and not global_set: 78 | # If the current group is global (ex: 'Optional arguments'), 79 | # overwriting parser main group before adding custom parameters 80 | global_set = True # The setup application should be run only once 81 | sub_parser._optionals.title = argument_group.title # Overwriting default argparse title 82 | group_parser = sub_parser # Subparser is directly used to add arguments 83 | else: 84 | # In every other case, a dedicated group is created in the parser 85 | group_parser = sub_parser.add_argument_group(argument_group.title, 86 | description=argument_group.description) 87 | # once the group is created in the parser, the arguments can be added to it 88 | option: Dict[str, Union[Option, bool]] 89 | for option in argument_group.options: 90 | # Retrieve Option object from the Dict 91 | assert type(option["arg"]) is Option 92 | argument = cast(Option, option["arg"]) 93 | # Pop is required here to removed unknown parameter from the action object before argparse 94 | completer = argument.kwargs.pop("completer", None) 95 | try: 96 | arg = group_parser.add_argument(*argument.args, **argument.kwargs) 97 | except argparse.ArgumentError: 98 | continue 99 | # Add argument with its config to the parser 100 | if completer is not None: 101 | arg.completer = completer # type: ignore 102 | 103 | def run_parser(self) -> argparse.Namespace: 104 | """Execute argparse to retrieve user options from argv""" 105 | argcomplete.autocomplete(self.__root_parser) 106 | return self.__root_parser.parse_args() 107 | 108 | def print_help(self) -> None: 109 | """Force argparse to display the help message""" 110 | self.__root_parser.print_help() 111 | -------------------------------------------------------------------------------- /exegol/utils/imgsync/ImageScriptSync.py: -------------------------------------------------------------------------------- 1 | import io 2 | import tarfile 3 | from typing import Optional 4 | 5 | from exegol.config.ConstantConfig import ConstantConfig 6 | from exegol.utils.ExeLog import logger 7 | 8 | 9 | class ImageScriptSync: 10 | 11 | @staticmethod 12 | def getCurrentStartVersion() -> str: 13 | """Find the current version of the spawn.sh script.""" 14 | with open(ConstantConfig.spawn_context_path_obj, 'r') as file: 15 | for line in file.readlines(): 16 | if line.startswith('# Spawn Version:'): 17 | return line.split(':')[-1].strip() 18 | logger.critical(f"The spawn.sh version cannot be found, check your exegol setup! {ConstantConfig.spawn_context_path_obj}") 19 | raise RuntimeError 20 | 21 | @staticmethod 22 | def getImageSyncTarData(include_entrypoint: bool = False, include_spawn: bool = False) -> Optional[bytes]: 23 | """The purpose of this class is to generate and overwrite scripts like the entrypoint or spawn.sh inside exegol containers 24 | to integrate the latest features, whatever the version of the image.""" 25 | 26 | # Create tar file 27 | stream = io.BytesIO() 28 | with tarfile.open(fileobj=stream, mode='w|') as entry_tar: 29 | 30 | # Load entrypoint data 31 | if include_entrypoint: 32 | entrypoint_script_path = ConstantConfig.entrypoint_context_path_obj 33 | logger.debug(f"Entrypoint script path: {str(entrypoint_script_path)}") 34 | if not entrypoint_script_path.is_file(): 35 | logger.critical("Unable to find the entrypoint script! Your Exegol installation is probably broken...") 36 | return None 37 | with open(entrypoint_script_path, 'rb') as f: 38 | raw = f.read() 39 | data = io.BytesIO(initial_bytes=raw) 40 | 41 | # Import file to tar object 42 | info = tarfile.TarInfo(name="/.exegol/entrypoint.sh") 43 | info.size = len(raw) 44 | info.mode = 0o500 45 | entry_tar.addfile(info, fileobj=data) 46 | 47 | # Load start data 48 | if include_spawn: 49 | spawn_script_path = ConstantConfig.spawn_context_path_obj 50 | logger.debug(f"Spawn script path: {str(spawn_script_path)}") 51 | if not spawn_script_path.is_file(): 52 | logger.error("Unable to find the spawn script! Your Exegol installation is probably broken...") 53 | return None 54 | with open(spawn_script_path, 'rb') as f: 55 | raw = f.read() 56 | data = io.BytesIO(initial_bytes=raw) 57 | 58 | # Import file to tar object 59 | info = tarfile.TarInfo(name="/.exegol/spawn.sh") 60 | info.size = len(raw) 61 | info.mode = 0o500 62 | entry_tar.addfile(info, fileobj=data) 63 | return stream.getvalue() 64 | -------------------------------------------------------------------------------- /exegol/utils/imgsync/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThePorgs/Exegol/3ef46e7fff8b45617dead7c6d5ea83635d609834/exegol/utils/imgsync/__init__.py -------------------------------------------------------------------------------- /exegol/utils/imgsync/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # SIGTERM received (the container is stopping, every process must be gracefully stopped before the timeout). 3 | trap shutdown SIGTERM 4 | 5 | function exegol_init() { 6 | usermod -s "/.exegol/spawn.sh" root > /dev/null 7 | } 8 | 9 | # Function specific 10 | function load_setups() { 11 | # Logs are using [INFO], [VERBOSE], [WARNING], [ERROR], [SUCCESS] tags so that the wrapper can catch them and forward them to the user with the corresponding logger level 12 | # Load custom setups (supported setups, and user setup) 13 | [[ -d "/var/log/exegol" ]] || mkdir -p /var/log/exegol 14 | if [[ ! -f "/.exegol/.setup.lock" ]]; then 15 | # Execute initial setup if lock file doesn't exist 16 | echo >/.exegol/.setup.lock 17 | # Run my-resources script. Logs starting with '[EXEGOL]' will be printed to the console and reported back to the user through the wrapper. 18 | if [ -f /.exegol/load_supported_setups.sh ]; then 19 | echo "[PROGRESS]Starting [green]my-resources[/green] setup" 20 | /.exegol/load_supported_setups.sh | grep --line-buffered '^\[EXEGOL]' | sed -u "s/^\[EXEGOL\]\s*//g" 21 | else 22 | echo "[WARNING]Your exegol image doesn't support my-resources custom setup!" 23 | fi 24 | fi 25 | } 26 | 27 | function finish() { 28 | echo "READY" 29 | } 30 | 31 | function endless() { 32 | # Start action / endless 33 | finish 34 | # Entrypoint for the container, in order to have a process hanging, to keep the container alive 35 | # Alternative to running bash/zsh/whatever as entrypoint, which is longer to start and to stop and to very clean 36 | [[ ! -p /tmp/.entrypoint ]] && mkfifo -m 000 /tmp/.entrypoint # Create an empty fifo for sleep by read. 37 | read -r <> /tmp/.entrypoint # read from /tmp/.entrypoint => endlessly wait without sub-process or need for TTY option 38 | } 39 | 40 | function shutdown() { 41 | # Shutting down the container. 42 | # Sending SIGTERM to all interactive process for proper closing 43 | pgrep vnc && desktop-stop # Stop webui desktop if started TODO improve desktop shutdown 44 | # shellcheck disable=SC2046 45 | kill $(pgrep -f -- openvpn | grep -vE '^1$') 2>/dev/null 46 | # shellcheck disable=SC2046 47 | kill $(pgrep -x -f -- zsh) 2>/dev/null 48 | # shellcheck disable=SC2046 49 | kill $(pgrep -x -f -- -zsh) 2>/dev/null 50 | # shellcheck disable=SC2046 51 | kill $(pgrep -x -f -- bash) 2>/dev/null 52 | # shellcheck disable=SC2046 53 | kill $(pgrep -x -f -- -bash) 2>/dev/null 54 | # Wait for every active process to exit (e.g: shell logging compression, VPN closing, WebUI) 55 | WAIT_LIST="$(pgrep -f "(.log|spawn.sh|vnc)" | grep -vE '^1$')" 56 | for i in $WAIT_LIST; do 57 | # Waiting for: $i PID process to exit 58 | tail --pid="$i" -f /dev/null 59 | done 60 | exit 0 61 | } 62 | 63 | function _resolv_docker_host() { 64 | # On docker desktop host, resolving the host.docker.internal before starting a VPN connection for GUI applications 65 | DOCKER_IP=$(getent ahostsv4 host.docker.internal | head -n1 | awk '{ print $1 }') 66 | if [[ "$DOCKER_IP" ]]; then 67 | # Add docker internal host resolution to the hosts file to preserve access to the X server 68 | echo "$DOCKER_IP host.docker.internal" >>/etc/hosts 69 | # If the container share the host networks, no need to add a static mapping 70 | ip route list match "$DOCKER_IP" table all | grep -v default || ip route add "$DOCKER_IP/32" "$(ip route list | grep default | head -n1 | grep -Eo '(via [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ )?dev [a-zA-Z0-9]+')" || echo '[WARNING]Exegol cannot add a static route to resolv your host X11 server. GUI applications may not work.' 71 | fi 72 | } 73 | 74 | function ovpn() { 75 | [[ "$DISPLAY" == *"host.docker.internal"* ]] && _resolv_docker_host 76 | if ! command -v openvpn &> /dev/null 77 | then 78 | echo '[ERROR]Your exegol image does not support the VPN feature' 79 | else 80 | # Starting openvpn as a job with '&' to be able to receive SIGTERM signal and close everything properly 81 | echo "[PROGRESS]Starting [green]VPN[/green]" 82 | # shellcheck disable=SC2164 83 | ([[ -d /.exegol/vpn/config ]] && cd /.exegol/vpn/config; openvpn --log-append /var/log/exegol/vpn.log "$@" &) 84 | sleep 2 # Waiting 2 seconds for the VPN to start before continuing 85 | fi 86 | 87 | } 88 | 89 | function run_cmd() { 90 | /bin/zsh -c "autoload -Uz compinit; compinit; source ~/.zshrc; eval \"$CMD\"" 91 | } 92 | 93 | function desktop() { 94 | if command -v desktop-start &> /dev/null 95 | then 96 | echo "[PROGRESS]Starting Exegol [green]desktop[/green] with [blue]${EXEGOL_DESKTOP_PROTO}[/blue]" 97 | ln -sf /root/.vnc /var/log/exegol/desktop 98 | desktop-start &>> ~/.vnc/startup.log # Disable logging 99 | sleep 2 # Waiting 2 seconds for the Desktop to start before continuing 100 | else 101 | echo '[ERROR]Your exegol image does not support the Desktop features' 102 | fi 103 | } 104 | 105 | ##### How "echo" works here with exegol ##### 106 | # 107 | # Every message printed here will be displayed to the console logs of the container 108 | # The container logs will be displayed by the wrapper to the user at startup through a progress animation (and a verbose line if -v is set) 109 | # The logs written to ~/banner.txt will be printed to the user through the .zshrc file on each new session (until the file is removed). 110 | # Using 'tee -a' after a command will save the output to a file AND to the console logs. 111 | # 112 | ############################################# 113 | echo "Starting exegol" 114 | exegol_init 115 | 116 | ### Argument parsing 117 | 118 | # Par each parameter 119 | for arg in "$@"; do 120 | # Check if the function exist 121 | FUNCTION_NAME=$(echo "$arg" | cut -d ' ' -f 1) 122 | if declare -f "$FUNCTION_NAME" > /dev/null; then 123 | $arg 124 | else 125 | echo "The function '$arg' doesn't exist." 126 | fi 127 | done 128 | -------------------------------------------------------------------------------- /exegol/utils/imgsync/spawn.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # DO NOT CHANGE the syntax or text of the following line, only increment the version number 4 | # Spawn Version:2 5 | # The spawn version allow the wrapper to compare the current version of the spawn.sh inside the container compare to the one on the current wrapper version. 6 | # On new container, this file is automatically updated through a docker volume 7 | # For legacy container, this version is fetch and the file updated if needed. 8 | 9 | function shell_logging() { 10 | # First parameter is the method to use for shell logging (default to script) 11 | local method=$1 12 | # The second parameter is the shell command to use for the user 13 | local user_shell=$2 14 | # The third enable compression at the end of the session 15 | local compress=$3 16 | 17 | # Test if the command is supported on the current image 18 | if ! command -v "$method" &> /dev/null 19 | then 20 | echo "Shell logging with $method is not supported by this image version, try with a newer one." 21 | $user_shell 22 | exit 0 23 | fi 24 | 25 | # Logging shell using $method and spawn a $user_shell shell 26 | 27 | umask 007 28 | mkdir -p /workspace/logs/ 29 | local filelog 30 | filelog="/workspace/logs/$(date +%d-%m-%Y_%H-%M-%S)_shell.${method}" 31 | 32 | case $method in 33 | "asciinema") 34 | # echo "Run using asciinema" 35 | asciinema rec -i 2 --stdin --quiet --command "$user_shell" --title "$(hostname | sed 's/^exegol-/\[EXEGOL\] /') $(date '+%d/%m/%Y %H:%M:%S')" "$filelog" 36 | ;; 37 | 38 | "script") 39 | # echo "Run using script" 40 | script -qefac "$user_shell" "$filelog" 41 | ;; 42 | 43 | *) 44 | echo "Unknown '$method' shell logging method, using 'script' as default shell logging method." 45 | script -qefac "$user_shell" "$filelog" 46 | ;; 47 | esac 48 | 49 | if [[ "$compress" = 'True' ]]; then 50 | echo 'compressing logs, please wait...' 51 | gzip "$filelog" 52 | fi 53 | exit 0 54 | } 55 | 56 | # Find default user shell to use from env var 57 | user_shell=${EXEGOL_START_SHELL:-"/bin/zsh"} 58 | 59 | # If shell logging is enable, the method to use is stored in env var 60 | if [ "$EXEGOL_START_SHELL_LOGGING" ]; then 61 | shell_logging "$EXEGOL_START_SHELL_LOGGING" "$user_shell" "$EXEGOL_START_SHELL_COMPRESS" 62 | else 63 | $user_shell 64 | fi 65 | 66 | exit 0 67 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["pdm-backend"] 3 | build-backend = "pdm.backend" 4 | 5 | [project] 6 | name = "Exegol" 7 | dynamic = ["version"] 8 | description = "Python wrapper to use Exegol, a container based fully featured and community-driven hacking environment." 9 | authors = [ 10 | { name = "Dramelac", email = "dramelac@pm.me" }, 11 | { name = "Shutdown", email = "nwodtuhs@pm.me" }, 12 | ] 13 | license = { file = "LICENSE" } 14 | readme = { file = "README.md", content-type = "text/markdown" } 15 | classifiers = [ 16 | "Development Status :: 5 - Production/Stable", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 23 | "Operating System :: OS Independent", 24 | ] 25 | keywords = ["pentest", "redteam", "ctf", "exegol"] 26 | 27 | requires-python = ">=3.9" 28 | dependencies = [ 29 | "docker~=7.1.0", 30 | "requests~=2.32.3", 31 | "rich~=13.7.1", 32 | "GitPython~=3.1.43", 33 | "PyYAML>=6.0.2", 34 | "argcomplete~=3.5.0", 35 | "tzlocal~=5.2; platform_system != 'Linux'" 36 | ] 37 | 38 | [dependency-groups] 39 | dev = ["pre-commit"] 40 | testing = ["mypy", "types-PyYAML", "types-requests", "types-tzlocal", "pytest"] 41 | 42 | [project.scripts] 43 | exegol = "exegol.manager.ExegolController:main" 44 | 45 | [project.urls] 46 | Homepage = "https://exegol.readthedocs.io/" 47 | Documentation = "https://exegol.readthedocs.io/" 48 | Repository = "https://github.com/ThePorgs/Exegol" 49 | Issues = "https://github.com/ThePorgs/Exegol/issues" 50 | Funding = "https://patreon.com/nwodtuhs" 51 | 52 | [tool.pdm] 53 | [tool.pdm.version] 54 | source = "file" 55 | path = "exegol/config/ConstantConfig.py" 56 | 57 | [tool.pdm.build] 58 | excludes = ["tests"] 59 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | docker~=7.1.0 2 | requests~=2.32.3 3 | rich~=13.7.1 4 | GitPython~=3.1.43 5 | PyYAML>=6.0.2 6 | argcomplete~=3.5.0 7 | tzlocal~=5.2; platform_system != 'Linux' 8 | -------------------------------------------------------------------------------- /setup.py.old: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from setuptools import setup, find_packages 4 | 5 | from exegol import __version__ 6 | 7 | here = pathlib.Path(__file__).parent.resolve() 8 | 9 | # Get the long description from the README file 10 | long_description = (here / 'README.md').read_text(encoding='utf-8') 11 | 12 | # Additional non-code data used by Exegol to build local docker image from source 13 | ## exegol-docker-build Dockerfiles 14 | source_directory = "exegol-docker-build" 15 | data_files_dict = {source_directory: [f"{source_directory}/Dockerfile"] + [str(profile) for profile in pathlib.Path(source_directory).rglob('*.dockerfile')]} 16 | data_files = [] 17 | # Add sources files recursively 18 | for path in pathlib.Path(f'{source_directory}/sources').rglob('*'): 19 | # Exclude directory path and exclude dockerhub hooks files 20 | if path.is_dir() or path.parent.name == "hooks": 21 | continue 22 | key = str(path.parent) 23 | if data_files_dict.get(key) is None: 24 | data_files_dict[key] = [] 25 | data_files_dict[key].append(str(path)) 26 | ## exegol scripts pushed from the wrapper 27 | data_files_dict["exegol-imgsync"] = ["exegol/utils/imgsync/entrypoint.sh", 28 | "exegol/utils/imgsync/spawn.sh"] 29 | 30 | # Dict to tuple 31 | for k, v in data_files_dict.items(): 32 | data_files.append((k, v)) 33 | 34 | setup( 35 | name='Exegol', 36 | version=__version__, 37 | license='GNU (GPLv3)', 38 | author="Shutdown & Dramelac", 39 | author_email='nwodtuhs@pm.me', 40 | description='Python wrapper to use Exegol, a container based fully featured and community-driven hacking environment.', 41 | long_description=long_description, 42 | long_description_content_type='text/markdown', 43 | python_requires='>=3.7, <4', 44 | url='https://github.com/ThePorgs/Exegol', 45 | keywords='pentest redteam ctf exegol', 46 | classifiers=[ 47 | 'Development Status :: 5 - Production/Stable', 48 | "Programming Language :: Python :: 3.7", 49 | "Programming Language :: Python :: 3.8", 50 | "Programming Language :: Python :: 3.9", 51 | "Programming Language :: Python :: 3.10", 52 | "Programming Language :: Python :: 3.11", 53 | "Programming Language :: Python :: 3.12", 54 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 55 | "Operating System :: OS Independent", 56 | ], 57 | install_requires=[ 58 | 'docker~=7.1.0', 59 | 'requests~=2.32.3', 60 | 'rich~=13.7.1', 61 | 'GitPython~=3.1.43', 62 | 'PyYAML>=6.0.2', 63 | 'argcomplete~=3.5.0', 64 | 'tzlocal~=5.2; platform_system != "Linux"' 65 | ], 66 | packages=find_packages(exclude=["tests"]), 67 | include_package_data=True, 68 | data_files=data_files, 69 | 70 | entry_points={ 71 | 'console_scripts': [ 72 | 'exegol = exegol.manager.ExegolController:main', 73 | ], 74 | }, 75 | 76 | project_urls={ 77 | 'Bug Reports': 'https://github.com/ThePorgs/Exegol/issues', 78 | 'Source': 'https://github.com/ThePorgs/Exegol', 79 | 'Documentation': 'https://exegol.readthedocs.io/', 80 | 'Funding': 'https://patreon.com/nwodtuhs', 81 | } 82 | ) 83 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from tests.test_exegol import test_version 2 | 3 | # Run with python3 setup.py test 4 | 5 | # Test version upgrade 6 | test_version() 7 | -------------------------------------------------------------------------------- /tests/exegol_test.py: -------------------------------------------------------------------------------- 1 | from exegol import __version__ 2 | 3 | 4 | def test_version(): 5 | assert __version__ == '4.3.7' 6 | --------------------------------------------------------------------------------