├── .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 ` (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 |
2 |

3 |
4 |

5 |

6 |

7 |
8 |

9 |

10 |
11 |

12 |

13 |

14 |
15 |
-supported-success)
16 |
-supported-success)
17 |
18 |

19 |

20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |

32 |
33 |
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 [:][:][:]. 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-)",
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][/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: ``` ```) "
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 |
--------------------------------------------------------------------------------