Skip to content

Commit

Permalink
Merge pull request #100 from ShutdownRepo/dev (4.0.3)
Browse files Browse the repository at this point in the history
Exegol 4.0.3
  • Loading branch information
Dramelac authored Jul 8, 2022
2 parents 4556aca + 63decd4 commit b996af0
Show file tree
Hide file tree
Showing 16 changed files with 428 additions and 84 deletions.
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@ Below are some bullet points to better understand how Exegol works
> Installing the wrapper and running it will do the next steps (which can be a bit lengthy)
## Pre-requisites
You need git, python3 and docker, and at least 20GB of free storage.
You need :
- git
- python3
- docker (running and accessible from user context. Tips for running docker without sudo: `sudo usermod -aG docker $(id -u -n)`)
- and at least 20GB of free storage

You also need python libraries listed in [requirements.txt](./requirements.txt) (installed automatically or manually depending on the installation method you choose).

## Installation using pip
Expand Down Expand Up @@ -198,6 +203,7 @@ Below is an example of a GUI app running in an Exegol container.
| neo4j database | neo4j | exegol4thewin |
| bettercap ui | bettercap | exegol4thewin |
| trilium | trilium | exegol4thewin |
| empire | empireadmin | exegol4thewin |
| wso-webshell (PHP) | | exegol4thewin |
</details>

Expand Down
3 changes: 2 additions & 1 deletion exegol/console/TUI.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,8 @@ def printContainerRecap(cls, container: ExegolContainerTemplate):
f"{'[bright_black](/opt/resources)[/bright_black]' if container.config.isExegolResourcesEnable() else ''}")
recap.add_row("[bold blue]My resources[/bold blue]", boolFormatter(container.config.isSharedResourcesEnable()) +
f"{'[bright_black](/my-resources)[/bright_black]' if container.config.isSharedResourcesEnable() else ''}")
recap.add_row("[bold blue]VPN[/bold blue]", container.config.getVpnName())
if "N/A" not in container.config.getVpnName():
recap.add_row("[bold blue]VPN[/bold blue]", container.config.getVpnName())
if container.config.getPrivileged() is True:
recap.add_row("[bold blue]Privileged[/bold blue]", '[orange3]On :fire:[/orange3]')
else:
Expand Down
14 changes: 11 additions & 3 deletions exegol/manager/ExegolManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def exec(cls):
if ParametersManager().tmp:
container = cls.__createTmpContainer(ParametersManager().selector)
if not ParametersManager().daemon:
container.exec(command=ParametersManager().exec, as_daemon=False)
container.exec(command=ParametersManager().exec, as_daemon=False, is_tmp=True)
container.stop(timeout=2)
else:
logger.success(f"Command executed as entrypoint of the container {container.hostname}")
Expand Down Expand Up @@ -167,7 +167,7 @@ def remove(cls):
for c in containers:
c.remove()
# If the image used is deprecated, it must be deleted after the removal of its container
if c.image.isLocked():
if c.image.isLocked() and UserConfig().auto_remove_images:
DockerUtils.removeImage(c.image, upgrade_mode=True)

@classmethod
Expand All @@ -178,13 +178,21 @@ def print_version(cls):
logger.debug(f"Pip installation: {boolFormatter(ConstantConfig.pip_installed)}")
logger.debug(f"Git source installation: {boolFormatter(ConstantConfig.git_source_installation)}")
logger.debug(f"Host OS: {EnvInfo.getHostOs()}")
logger.debug(f"Arch: {EnvInfo.arch}")
if EnvInfo.isWindowsHost():
logger.debug(f"Windows release: {EnvInfo.getWindowsRelease()}")
logger.debug(f"Python environment: {EnvInfo.current_platform}")
logger.debug(f"Docker engine: {EnvInfo.getDockerEngine().upper()}")
logger.debug(f"Docker desktop: {boolFormatter(EnvInfo.isDockerDesktop())}")
logger.debug(f"Shell type: {EnvInfo.getShellType()}")
logger.empty_line(log_level=logging.DEBUG)
if not UpdateManager.isUpdateTag() and UserConfig().auto_check_updates:
UpdateManager.checkForWrapperUpdate()
if UpdateManager.isUpdateTag():
logger.empty_line()
if Confirm("An [green]Exegol[/green] update is [orange3]available[/orange3], do you want to update ?", default=True):
UpdateManager.updateWrapper()
else:
logger.empty_line(log_level=logging.DEBUG)

@classmethod
def __loadOrInstallImage(cls,
Expand Down
123 changes: 122 additions & 1 deletion exegol/manager/UpdateManager.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import os
import re
from datetime import datetime, timedelta, date
from typing import Optional, Dict, cast, Tuple, Sequence

from rich.prompt import Prompt
Expand All @@ -12,10 +15,14 @@
from exegol.utils.DockerUtils import DockerUtils
from exegol.utils.ExeLog import logger, console, ExeLog
from exegol.utils.GitUtils import GitUtils
from exegol.utils.WebUtils import WebUtils


class UpdateManager:
"""Procedure class for updating the exegol tool and docker images"""
__UPDATE_TAG_FILE = ".update.meta"
__LAST_CHECK_FILE = ".lastcheck.meta"
__TIME_FORMAT = "%d/%m/%Y"

@classmethod
def updateImage(cls, tag: Optional[str] = None, install_mode: bool = False) -> Optional[ExegolImage]:
Expand Down Expand Up @@ -84,7 +91,10 @@ def __askToBuild(cls, tag: str) -> Optional[ExegolImage]:
@classmethod
def updateWrapper(cls) -> bool:
"""Update wrapper source code from git"""
return cls.__updateGit(ExegolModules().getWrapperGit())
result = cls.__updateGit(ExegolModules().getWrapperGit())
if result:
cls.__untagUpdateAvailable()
return result

@classmethod
def updateImageSource(cls) -> bool:
Expand Down Expand Up @@ -151,6 +161,116 @@ def __updateGit(gitUtils: GitUtils) -> bool:
logger.empty_line()
return True

@classmethod
def checkForWrapperUpdate(cls) -> bool:
"""Check if there is an exegol wrapper update available.
Return true if an update is available."""
# Skipping update check
if cls.__triggerUpdateCheck():
logger.debug("Running update check")
return cls.__checkUpdate()
return False

@classmethod
def __triggerUpdateCheck(cls):
"""Check if an update check must be triggered.
Return true to check for new update"""
if (ConstantConfig.exegol_config_path / cls.__LAST_CHECK_FILE).is_file():
with open(ConstantConfig.exegol_config_path / cls.__LAST_CHECK_FILE, 'r') as metafile:
lastcheck = datetime.strptime(metafile.read().strip(), cls.__TIME_FORMAT)
else:
return True
logger.debug(f"Last update check: {lastcheck.strftime(cls.__TIME_FORMAT)}")
now = datetime.now()
if lastcheck > now:
logger.debug("Incoherent last check date detected. Updating metafile.")
return True
# Check for a new update after at least 15 days
time_delta = timedelta(days=15)
return (lastcheck + time_delta) < now

@classmethod
def __checkUpdate(cls):
isUpToDate = True
with console.status("Checking for wrapper update. Please wait.", spinner_style="blue"):
if re.search(r'[a-z]', ConstantConfig.version, re.IGNORECASE):
# Dev version have a letter in the version code and must check updates via git
logger.debug("Checking update using: dev mode")
module = ExegolModules().getWrapperGit(fast_load=True)
if module.isAvailable:
isUpToDate = module.isUpToDate()
else:
# If Exegol have not been installed from git clone. Auto-check update in this case is only available from mates release
logger.verbose("Auto-update checking is not available in the current context")
else:
# If there is no letter, it's a stable release, and we can compare faster with the latest git tag
logger.debug("Checking update using: stable mode")
try:
remote_version = WebUtils.getLatestWrapperRelease()
isUpToDate = cls.__compareVersion(remote_version)
except CancelOperation:
# No internet, postpone update check
pass

if not isUpToDate:
cls.__tagUpdateAvailable()
cls.__updateLastCheckFile()
return not isUpToDate

@classmethod
def __updateLastCheckFile(cls):
with open(ConstantConfig.exegol_config_path / cls.__LAST_CHECK_FILE, 'w') as metafile:
metafile.write(date.today().strftime(cls.__TIME_FORMAT))

@classmethod
def __compareVersion(cls, version) -> bool:
isUpToDate = True
try:
for i in range(len(version.split('.'))):
remote = int(version.split('.')[i])
local = int(ConstantConfig.version.split('.')[i])
if remote > local:
isUpToDate = False
break
except ValueError:
logger.warning(f'Unable to parse Exegol version : {version} / {ConstantConfig.version}')
return isUpToDate

@classmethod
def __tagUpdateAvailable(cls):
"""Create the 'update available' cache file."""
if not ConstantConfig.exegol_config_path.is_dir():
logger.verbose(f"Creating exegol home folder: {ConstantConfig.exegol_config_path}")
os.mkdir(ConstantConfig.exegol_config_path)
tag_file = ConstantConfig.exegol_config_path / cls.__UPDATE_TAG_FILE
if not tag_file.is_file():
with open(tag_file, 'w') as lockfile:
lockfile.write(ConstantConfig.version)

@classmethod
def isUpdateTag(cls) -> bool:
"""Check if the cache file is present to announce an available update of the exegol wrapper."""
if (ConstantConfig.exegol_config_path / cls.__UPDATE_TAG_FILE).is_file():
# Fetch the previously locked version
with open(ConstantConfig.exegol_config_path / cls.__UPDATE_TAG_FILE, 'r') as lockfile:
locked_version = lockfile.read()
# If the current version is the same, no external update had occurred
if locked_version == ConstantConfig.version:
return True
else:
# If the version changed, exegol have been updated externally (via pip for example)
cls.__untagUpdateAvailable()
return False
else:
return False

@classmethod
def __untagUpdateAvailable(cls):
"""Remove the 'update available' cache file."""
tag_file = ConstantConfig.exegol_config_path / cls.__UPDATE_TAG_FILE
if tag_file.is_file():
os.remove(tag_file)

@classmethod
def __buildSource(cls, build_name: Optional[str] = None) -> str:
"""build user process :
Expand Down Expand Up @@ -216,6 +336,7 @@ def listBuildProfiles(cls) -> Dict:

@classmethod
def listGitStatus(cls) -> Sequence[Dict[str, str]]:
"""Get status of every git modules"""
result = []
gits = [ExegolModules().getWrapperGit(fast_load=True),
ExegolModules().getSourceGit(fast_load=True),
Expand Down
31 changes: 28 additions & 3 deletions exegol/model/ContainerConfig.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import os
import re
from pathlib import Path, PurePath
Expand Down Expand Up @@ -379,6 +380,7 @@ def enableVPN(self, config_path: Optional[str] = None):
self.__addCapability("NET_ADMIN")
if not self.__network_host:
# Add sysctl ipv6 config, some VPN connection need IPv6 to be enabled
# TODO test with ipv6 disable with kernel modules
self.__addSysctl("net.ipv6.conf.all.disable_ipv6", "0")
# Add tun device, this device is needed to create VPN tunnels
self.__addDevice("/dev/net/tun", mknod=True)
Expand Down Expand Up @@ -419,18 +421,20 @@ def __prepareVpnVolumes(self, config_path: Optional[str]) -> Optional[str]:
logger.debug(f"Adding VPN from: {str(vpn_path.absolute())}")
self.__vpn_path = vpn_path
if vpn_path.is_file():
self.__checkVPNConfigDNS(vpn_path)
# Configure VPN with single file
self.addVolume(str(vpn_path.absolute()), "/vpn/config/client.ovpn", read_only=True)
ovpn_parameters.append("--config /vpn/config/client.ovpn")
else:
# Configure VPN with directory
logger.verbose(
"Folder detected for VPN configuration. only the first *.ovpn file will be automatically launched when the container starts.")
logger.verbose("Folder detected for VPN configuration. "
"Only the first *.ovpn file will be automatically launched when the container starts.")
self.addVolume(str(vpn_path.absolute()), "/vpn/config", read_only=True)
vpn_filename = None
# Try to find the config file in order to configure the autostart command of the container
for file in vpn_path.glob('*.ovpn'):
logger.info(f"Using VPN config: {file}")
self.__checkVPNConfigDNS(file)
# Get filename only to match the future container path
vpn_filename = file.name
ovpn_parameters.append(f"--config /vpn/config/{vpn_filename}")
Expand All @@ -442,6 +446,24 @@ def __prepareVpnVolumes(self, config_path: Optional[str]) -> Optional[str]:

return ' '.join(ovpn_parameters)

@staticmethod
def __checkVPNConfigDNS(vpn_path: Union[str, Path]):
logger.verbose("Checking OpenVPN config file")
configs = ["script-security 2", "up /etc/openvpn/update-resolv-conf", "down /etc/openvpn/update-resolv-conf"]
with open(vpn_path, 'r') as vpn_file:
for line in vpn_file:
line = line.strip()
if line in configs:
configs.remove(line)
if len(configs) > 0:
logger.warning("Some OpenVPN config are [red]missing[/red] to support VPN [orange3]dynamic DNS servers[/orange3]! Please add the following line to your configuration file:")
logger.empty_line()
logger.raw(os.linesep.join(configs), level=logging.WARNING)
logger.empty_line()
logger.empty_line()
logger.info("Press enter to continue or Ctrl+C to cancel the operation")
input()

def __disableVPN(self) -> bool:
"""Remove a VPN profile for container startup (Only for interactive config)"""
if self.__vpn_path:
Expand Down Expand Up @@ -821,7 +843,10 @@ def getTextFeatures(self, verbose: bool = False) -> str:
result += f"{getColor(self.__exegol_resources)[0]}Exegol resources: {boolFormatter(self.__exegol_resources)}{getColor(self.__exegol_resources)[1]}{os.linesep}"
if verbose or not self.__shared_resources:
result += f"{getColor(self.__shared_resources)[0]}My resources: {boolFormatter(self.__shared_resources)}{getColor(self.__shared_resources)[1]}{os.linesep}"
return result.strip()
result = result.strip()
if not result:
return "[i][bright_black]Default configuration[/bright_black][/i]"
return result

def getTextMounts(self, verbose: bool = False) -> str:
"""Text formatter for Mounts configurations. The verbose mode does not exclude technical volumes."""
Expand Down
14 changes: 9 additions & 5 deletions exegol/model/ExegolContainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,11 @@ def spawnShell(self):
# environment=self.config.getShellEnvs())
# logger.debug(result)

def exec(self, command: Sequence[str], as_daemon: bool = True, quiet: bool = False):
"""Execute a command / process on the docker container"""
def exec(self, command: Sequence[str], as_daemon: bool = True, quiet: bool = False, is_tmp: bool = False):
"""Execute a command / process on the docker container.
Set as_daemon to not follow the command stream and detach the execution
Set quiet to disable logs message
Set is_tmp if the container will automatically be removed after execution"""
if not self.isRunning():
self.start()
if not quiet:
Expand All @@ -141,9 +144,9 @@ def exec(self, command: Sequence[str], as_daemon: bool = True, quiet: bool = Fal
if not quiet:
logger.success("End of the command")
except KeyboardInterrupt:
if not quiet:
if not quiet and not is_tmp:
logger.info("Detaching process logging")
logger.warning("Exiting this command does NOT stop the process in the container")
logger.warning("Exiting this command does [red]NOT[/red] stop the process in the container")

@staticmethod
def formatShellCommand(command: Sequence[str], quiet: bool = False):
Expand All @@ -154,7 +157,8 @@ def formatShellCommand(command: Sequence[str], quiet: bool = False):
logger.success(f"Command received: {str_cmd}")
cmd_b64 = base64.b64encode(str_cmd.encode('utf-8')).decode('utf-8')
# Load zsh aliases and call eval to force aliases interpretation
cmd = f'zsh -c "source /opt/.zsh_aliases; eval $(echo {cmd_b64} | base64 -d)"'
# TODO remove grep -v (time for user to update exegol image)
cmd = f'zsh -c "source <(grep -v oh-my-zsh.sh ~/.zshrc); eval $(echo {cmd_b64} | base64 -d)"'
logger.debug(f"Formatting zsh command: {cmd}")
return cmd

Expand Down
Loading

0 comments on commit b996af0

Please sign in to comment.