24 Commits

Author SHA1 Message Date
ff97ed5b20 Merge pull request '9.07.2025-gpg-ipmort-public-key' () from 9.07.2025-gpg-ipmort-public-key into main
Reviewed-on: 
2025-07-09 13:59:35 +02:00
fbba0028ab german translate fix and remove suport on Open Suse 2025-07-09 13:34:39 +02:00
9873a293f7 replace networkmanager comand with systemctl for Suse 2025-07-09 11:45:16 +02:00
29f237ab42 Merge pull request 'readme update 3' () from 9.07.2025-gpg-ipmort-public-key into main
Reviewed-on: 
2025-07-09 10:17:37 +02:00
5986ce3b48 readme update 3 2025-07-09 10:17:01 +02:00
bc1ec45ecd Merge pull request 'readme update2' () from 9.07.2025-gpg-ipmort-public-key into main
Reviewed-on: 
2025-07-09 10:14:24 +02:00
4006b917f9 readme update2 2025-07-09 10:13:39 +02:00
9db3aff611 Merge pull request 'update readme file' () from 9.07.2025-gpg-ipmort-public-key into main
Reviewed-on: 
2025-07-09 09:51:16 +02:00
aa8923ca47 update readme file 2025-07-09 09:50:28 +02:00
ac3a375357 Merge pull request 'update language file' () from 9.07.2025-gpg-ipmort-public-key into main
Reviewed-on: 
2025-07-09 09:48:05 +02:00
c500b4f1ea update language file 2025-07-09 09:47:05 +02:00
80a7018a72 Merge pull request '9.07.2025-gpg-ipmort-public-key' () from 9.07.2025-gpg-ipmort-public-key into main
Reviewed-on: 
2025-07-09 09:17:55 +02:00
cfebeafd9b readme file extended with fingerprint and description 2025-07-09 09:14:41 +02:00
299404eaac add files to ignore list 2025-07-09 08:57:19 +02:00
be43e50065 Merge pull request '9.07.2025-gpg-ipmort-public-key' () from 9.07.2025-gpg-ipmort-public-key into main
Reviewed-on: 
2025-07-09 08:54:23 +02:00
44e75fa1b0 add gpg public_key ipmort 2025-07-09 08:49:49 +02:00
f416f66ee1 Dateien nach "/" hochladen 2025-07-06 16:23:56 +02:00
12904e843c 29.06.2025 (show changelog) 2025-07-02 12:40:08 +02:00
e824094556 ssl fix and remove ssl key and userfiles works 2025-06-24 15:04:32 +02:00
ed269af1d2 add requests for arch linux 2025-06-23 14:44:33 +02:00
01bd6ab263 language works in appimage 2025-06-21 11:09:48 +02:00
a63d54f128 extract dirs in current directory works on appimage 2025-06-20 23:41:32 +02:00
3ba041a28e Fix Clear Log Button 2025-06-18 18:36:34 +02:00
a9b6fccbf7 zwischenstand button wird nur als strich angeizeigt 2025-06-18 14:39:33 +02:00
14 changed files with 1747 additions and 531 deletions

32
.gitignore vendored

@@ -4,3 +4,35 @@ debug.log
.idea .idea
.vscode .vscode
__pycache__ __pycache__
# Build-Artefakte
build/
dist/
certs/
*.spec.bak
lxtools_installer
lxtools_installer.AppImage
lxtools_installer_compat
build_compatible.sh
build_local.sh
clean_build.sh
start_builder.sh
test_extract.py
test_paths.py
test_resources.py
manager_fixed.py
test_simple.sh
test_container.sh
gpg_setup.sh
gpg_simple_setup.sh
lxtools_installer.spec
lxtoolsinstaller.pot
# Docker-Build
docker_build/
debug_docker.sh
Dockerfile.nuitka
DOCKER_BUILD_ANLEITUNG.md
nuitka_builder.py
Dockerfile.test
Dockerfile.simple

@@ -2,7 +2,85 @@ Changelog for LXTools installer
## [Unreleased] ## [Unreleased]
- replace pack with grid - In the future, lxtools_installer will use the /tmp working
directory for extracting necessary files from the AppImage. Currently,
required files are extracted into separate folders using additional
methods, which would then be removed. Depending on how the installer
is called, examples include being invoked in the home directory where
needed folders and files are unpacked there, and upon closing the app,
these unpacked files will be automatically deleted. Additionally,
a folder is created in the /tmp directory. This folder is used
when the installer is called from an installed program to avoid
permission issues and to prevent extracting into a bin folder.
### Added
09.07.2025
- gpg check, download and import public_key.asc from two sources
automatically and check if the signature is valid
- Check checksumm and signature of the AppImage-File
- Methods for checking pkexec and NetworkManager extended for Fedora
and Open Suse if it is displayed incorrectly, the appimage can be started
in terminal to see where the problem is
### Added
02.07.2025
- build dockercontainer (ubuntu 22.04) for build appimage
the app installer is now running on Debian 12
- first complete test runs on Debian12, Linux Mint 22.1,
Open Suse (Leap and Thumbleweed) and Fedora,
at Arch linux the installer starts only if xorg-xrandr is missing
- the installable programs also run on all systems mentioned
and should also be running on other derivatives.
### Added
29.06.2025
- add methode sigi, clean_files and remove_lxtools_files
for remove files and dirs on close lxtools_installer
- fix message dialog on font and padding
- add methods check polkit and check Networkmanager is installed
and view in header is result false
### Added
23-06-2025
- Add unzip check, requests check, wget check for Arch Linux
- fix remove config dirs and logfile and ssl privatkey on uninstall
- method the number of users of the system is extended
for information message when a program is uninstalled
and there are more than one user on the system.
### Added
22-06-2025
- ssl certificate integrate in Appinstaller
- Installer now takes into account the current python
version ud the respective recognized system to run with
it on all supported systems.
### Added
21-06-2025
- extract now files needed in the work directory Theme, Icons and translation
- if python is not found, it displays this in red in the header,
with new get_python_version method
### Added ### Added
18-06-2025 18-06-2025
@@ -15,6 +93,7 @@ Changelog for LXTools installer
- Installer divided into several modules and added new MessageDialog module - Installer divided into several modules and added new MessageDialog module
### Added ### Added
4-06-2025 4-06-2025
@@ -23,6 +102,7 @@ Changelog for LXTools installer
- add ensure_shared_libs_pth_exists Script to install - add ensure_shared_libs_pth_exists Script to install
### Added ### Added
4-06-2025 4-06-2025

@@ -2,6 +2,26 @@
LX Tools Installer is a GUI for simple install, update, and remove Apps from ilunix.de LX Tools Installer is a GUI for simple install, update, and remove Apps from ilunix.de
# Fingerprint
743745087C6414E00F1EF84D4CCF06B6CE2A4C7F
add to your gpg keyring:
```bash
wget https://git.ilunix.de/punix/lxtools_installer/raw/branch/main/public_key.asc -O - | gpg --import
```
or
```bash
wget https://keys.openpgp.org/vks/v1/by-fingerprint/743745087C6414E00F1EF84D4CCF06B6CE2A4C7F -O - | gpg --import
```
The Appimage automatically checks whether the public_key has already been imported,
and if not it is downloaded from both sources and only imported when all the keys match.
This is to ensure that no manipulated software is used.
# Not currently supported
- Open Suse Tumbleweed and Leap (Let's get back)
# Screenshots # Screenshots
[![wire-py.png](https://fb.ilunix.de/api/public/dl/ZnfG9gxv?inline=true)](https://fb.ilunix.de/share/ZnfG9gxv) [![wire-py.png](https://fb.ilunix.de/api/public/dl/ZnfG9gxv?inline=true)](https://fb.ilunix.de/share/ZnfG9gxv)

@@ -1,68 +0,0 @@
import sys
import os
# ✅ Path to be added in the .pth file
SHARED_LIBS_PATH = "/usr/local/share/shared_libs"
PTH_FILE_NAME = "shared_libs.pth"
def ensure_shared_libs_pth_exists():
"""
Checks if all site-packages directories have a `.pth` file with the correct path.
Creates or updates it if missing or incorrect.
"""
# Search for all site-packages directories (e.g., /usr/lib/python3.x/site-packages/)
for root, dirs, files in os.walk("/usr"):
if "site-packages" in dirs:
site_packages_dir = os.path.join(root, "site-packages")
pth_file_path = os.path.join(site_packages_dir, PTH_FILE_NAME)
# Check if the file exists and is correct
if not os.path.exists(pth_file_path):
print(f"⚠️ .pth file not found: {pth_file_path}. Creating...")
with open(pth_file_path, "w") as f:
f.write(SHARED_LIBS_PATH + "\n")
else:
# Check if the correct path is in the file
with open(pth_file_path, "r") as f:
content = f.read().strip()
if not content == SHARED_LIBS_PATH:
print(f"⚠️ .pth file exists but has incorrect content. Fixing...")
with open(pth_file_path, "w") as f:
f.write(SHARED_LIBS_PATH + "\n")
print("✅ All .pth files checked and corrected.")
def main():
try:
# Try to import the module
from shared_libs.wp_app_config import AppConfig
print("'shared_libs' is correctly loaded. Starting the application...")
# Your main program logic here...
except ModuleNotFoundError as e:
# Only handle errors related to missing .pth file
if "No module named 'shared_libs'" in str(e):
print("⚠️ Error: 'shared_libs' module not found. Checking .pth file...")
ensure_shared_libs_pth_exists()
# Try again after fixing the .pth file
try:
from shared_libs.wp_app_config import AppConfig
print("✅ After correcting the .pth file: Module loaded.")
# Your main program logic here...
except Exception as e2:
print(f"❌ Error after correcting the .pth file: {e2}")
else:
# For other errors, re-raise them
raise
if __name__ == "__main__":
main()

Binary file not shown.

BIN
lx-icons/16/settings.png Normal file

Binary file not shown.

After

(image error) Size: 757 B

BIN
lx-icons/16/wg_vpn.png Normal file

Binary file not shown.

After

(image error) Size: 846 B

BIN
lx-icons/32/lxtools_key.png Normal file

Binary file not shown.

After

(image error) Size: 1.3 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@@ -129,7 +129,7 @@ class MessageDialog:
self.window = tk.Toplevel(master) self.window = tk.Toplevel(master)
self.window.grab_set() self.window.grab_set()
self.window.resizable(False, False) self.window.resizable(False, False)
ttk.Style().configure("TButton", font=("Helvetica", 11), padding=5) ttk.Style().configure("TButton")
self.buttons_widgets = [] self.buttons_widgets = []
self.current_button_index = 0 self.current_button_index = 0
self._load_icons() self._load_icons()

@@ -1,28 +1,428 @@
import socket import socket
import os
import urllib.request import urllib.request
import json import json
import hashlib
import subprocess
from typing import Union, List
import re
import tempfile
class GiteaUpdate: class GPGManager:
@staticmethod @staticmethod
def api_down(url, current_version=""): def get_gpg() -> bool:
"""Get latest version from Gitea API""" """
Check if gpg is installed.
Returns:
bool: True if `gpg` is installed, False otherwise.
"""
result = subprocess.run(
["which gpg || command -v gpg"],
capture_output=True,
shell=True,
text=True,
check=False,
)
if result.returncode == 0:
return True
else:
return False
@staticmethod
def is_key_already_imported(key_id: str) -> bool:
"""
Prüft, ob der Schlüssel bereits im Keyring importiert ist.
Args:
key_id (str): ID des öffentlichen Schlüssels (z. B. '7D8A6E1F9B4C3A5D...')
Returns:
bool: True, wenn der Schlüssel vorhanden ist.
"""
from message import MessageDialog
from manager import LocaleStrings
# Check if `gpg` is installed
if not GPGManager.get_gpg():
MessageDialog("warning", LocaleStrings.MSGGPG["gpg_missing"]).show()
return False
try:
result = subprocess.run(
["gpg", "--list-keys", key_id],
capture_output=True,
text=True,
check=True,
)
if key_id in result.stdout:
return True
else:
return False
except Exception as e:
if e.returncode == 2:
return False
else:
MessageDialog(
"error", f"{LocaleStrings.MSGA['error_gpg_check']}{e}"
).show()
return False
@staticmethod
def is_url_reachable(url: str, timeout: int = 5) -> bool:
"""
Checks if a given URL is reachable.
Args:
url (str): The URL to check.
timeout (int): Timeout in seconds for the connection attempt.
Returns:
bool: True if the URL is reachable, False otherwise.
"""
try:
urllib.request.urlopen(url, timeout=timeout)
return True
except Exception as e:
from message import MessageDialog
from manager import LocaleStrings
MessageDialog(
"error",
f"{LocaleStrings.MSGGPG['url_not_reachable']}{url} - {LocaleStrings.MSGA['error']}{e}",
)
return False
@staticmethod
def import_key_from_url(
key_url: str, expected_fingerprint: str, filename: str
) -> bool:
"""
Downloads a GPG public key from the given URL, verifies its fingerprint matches
the expected value, and imports it into the local GPG keyring.
Args:
key_url (str): URL to the `.asc` public key file.
expected_fingerprint (str): Expected 40-character hexadecimal fingerprint of the key.
filename (str): Destination filename for saving the downloaded key in `PUBLIC_KEYS_DIR`.
Returns:
bool: True if the key was downloaded, verified, and successfully imported. False otherwise.
"""
from message import MessageDialog
from manager import LocaleStrings
# Configuration: Define the directory for storing public key files
PUBLIC_KEYS_DIR = "/tmp/public_keys"
if not os.path.exists(PUBLIC_KEYS_DIR):
os.makedirs(PUBLIC_KEYS_DIR)
try:
# Construct full path to save the key file
key_path = os.path.join(PUBLIC_KEYS_DIR, filename)
print(f"Downloading public key from {key_url} to {key_path}")
urllib.request.urlretrieve(key_url, key_path)
result = subprocess.run(
["gpg", "-fingerprint", key_path],
capture_output=True,
text=True,
check=True,
)
if result.returncode == 0:
# Extract all fingerprints from GPG output (40-character hexadecimal)
fingerprints_from_key: List[str] = []
for line in result.stdout.splitlines():
matches = re.findall(r"\b[0-9A-Fa-f]{40}\b", line)
fingerprints_from_key.extend(matches)
if not fingerprints_from_key:
MessageDialog(
"warning", LocaleStrings.MSGGPG["corrupted_file"]
).show()
return False
# Check if any of the extracted fingerprints match the expected one
found = any(fp == expected_fingerprint for fp in fingerprints_from_key)
if not found:
MessageDialog(
"warning",
f"{LocaleStrings.MSGGPG['mismatch']}\n{expected_fingerprint}\n{LocaleStrings.MSGGPG['but_got']}\n{fingerprints_from_key}",
wraplength=450,
).show()
return False
else:
# Import key into GPG keyring
result_import = subprocess.run(
["gpg", "--import", key_path],
capture_output=True,
text=True,
check=True,
)
if result_import.returncode == 0:
print(f"Public key from {key_url} successfully imported.")
return True
else:
MessageDialog(
"error",
f"{LocaleStrings.MSGGPG['failed_import']}{result_import.stderr}",
).show()
return False
else:
MessageDialog(
"error",
f"{LocaleStrings.MSGGPG['fingerprint_extract']}{result.stderr}",
)
return False
except Exception as e:
MessageDialog(
"error", f"{LocaleStrings.MSGGPG['error_import_key']}{key_url}: {e}"
)
return False
@staticmethod
def update_gpg_trust_level(key_id: str, trust_level: int = 5) -> bool:
"""
Sets the trust level for a specified GPG public key.
Args:
key_id (str): The hexadecimal ID of the key to update.
trust_level (int): Trust level (1-5). Default is 5 (fully trusted).
Returns:
bool: True if the trust level was successfully updated, False otherwise.
"""
script = f"""#!/bin/bash
# Set required environment variables
export GPG_TTY=$(tty)
export GNUPG_STATUS="1"
export GNUPGAGENT_INFO_FILE="/dev/null"
gpg --batch \
--no-tty \
--command-fd 0 \
--edit-key {key_id} << EOF
trust
{trust_level}
quit
EOF
"""
try:
from message import MessageDialog
from manager import LocaleStrings
# Create temporary script file
temp_script = tempfile.NamedTemporaryFile(
mode="w", suffix=".sh", delete=False, encoding="utf-8"
)
temp_script.write(script)
temp_script.close()
print(f"Setting trust level {trust_level} for key {key_id}")
result = subprocess.run(
["bash", temp_script.name], capture_output=True, text=True, check=True
)
if result.returncode != 0:
MessageDialog(
"error",
f"{LocaleStrings.MSGGPG['error_updating_trust_level']}{result.stderr}",
).show()
except (subprocess.CalledProcessError, FileNotFoundError) as e:
MessageDialog(
"error", f"{LocaleStrings.MSGGPG['error_executing_script']}{e}"
).show()
return False
finally:
try:
os.unlink(temp_script.name)
except OSError:
pass
return True
class GiteaUpdater:
@staticmethod
def get_latest_version_from_api(url: str, current_version: str = "") -> str:
"""
Fetches the latest version of a project from the Gitea API.
Args:
url (str): The URL to query the Gitea API for releases.
current_version (str, optional): Not used in this implementation. Defaults to "".
Returns:
str: Latest version tag name without the 'v' prefix.
If an error occurs, returns "Unknown".
"""
try: try:
with urllib.request.urlopen(url, timeout=10) as response: with urllib.request.urlopen(url, timeout=10) as response:
data = json.loads(response.read().decode()) data = json.loads(response.read().decode())
if data and len(data) > 0: if data and len(data) > 0:
latest_version = data[0].get("tag_name", "Unknown") latest_version = data[0].get("tag_name", "Unknown")
return latest_version.lstrip("v") # Remove 'v' prefix if present return latest_version.lstrip("v") # Remove 'v' prefix
return "Unknown" return "Unknown"
except Exception as e: except Exception as e:
print(f"API Error: {e}") print(f"API Error: {e}")
return "Unknown" return "Unknown"
@staticmethod
def download_appimage(
version: str = "latest",
verify_checksum: bool = False,
verify_signature: bool = False,
) -> Union[str, None]:
"""
Downloads the AppImage file to /tmp/portinstaller and performs verification checks.
Args:
version (str): 'latest' for the latest version or a specific version.
verify_checksum (bool): Whether to perform SHA256 checksum verification.
verify_signature (bool): Whether to validate GPG signature.
Returns:
str: Full path to the AppImage if all checks pass. Otherwise, returns an error message.
None: If an exception occurs during download or verification.
"""
base_url = "https://git.ilunix.de/punix/lxtools_installer/releases/download/"
from message import MessageDialog
from manager import LocaleStrings
# Get latest version from API if not provided
if version == "latest":
try:
with urllib.request.urlopen(
"https://git.ilunix.de/api/v1/repos/punix/lxtools_installer/releases?limit=1",
timeout=10,
) as response:
data = json.loads(response.read().decode())
latest_version = data[0].get("tag_name")
if not latest_version:
print(LocaleStrings.MSGA["Failed_retrieving"])
return None
except Exception as e:
print(f"{LocaleStrings.MSGA['Error_retrieving']}{e}")
return None
else:
latest_version = version
# Create /tmp/portinstaller directory if it doesn't exist
download_dir = "/tmp/portinstaller"
os.makedirs(download_dir, exist_ok=True)
filename = f"{download_dir}/lxtools_installer{latest_version}-x86_64.AppImage"
appimage_url = f"{base_url}{latest_version}/{filename.split('/')[-1]}"
checksum_url = f"{base_url}{latest_version}/{filename.split('/')[-1]}.sha256"
signature_url = f"{base_url}{latest_version}/{filename.split('/')[-1]}.asc"
try:
print("Downloading AppImage Updater...")
urllib.request.urlretrieve(appimage_url, filename)
# SHA256 checksum verification
result = None
if verify_checksum:
try:
with urllib.request.urlopen(checksum_url, timeout=10) as response:
checksum_content = response.read().decode()
expected_hash, _ = checksum_content.strip().split(" ")
except Exception as e:
result = MessageDialog(
"ask",
f"{LocaleStrings.MSGA['SHA256_File_not_found']}{e} {LocaleStrings.MSGA['SHA256_File_not_found1']}",
buttons=["Yes", "No"],
).show()
if not result:
return "Checksum file not found"
if result:
pass
else:
with open(filename, "rb") as f:
file_hash = hashlib.sha256(f.read()).hexdigest()
if expected_hash != file_hash:
MessageDialog(
"warning", LocaleStrings.MSGA["SHA256 hash mismatch"]
).show()
return "Checksum mismatch"
# GPG signature verification
if verify_signature:
signature_path = f"{download_dir}/{filename.split('/')[-1]}.asc"
try:
urllib.request.urlretrieve(signature_url, signature_path)
except Exception as e:
MessageDialog(
"error", f"{LocaleStrings.MSGA['not_gpg_found']}{e}"
).show()
return "Signature file not found"
try:
result = subprocess.run(
["gpg", "--verify", signature_path, filename],
capture_output=True,
text=True,
check=True,
)
if result.returncode == 0:
print(LocaleStrings.MSGA["gpg_verify_success"])
except Exception as e:
from message import MessageDialog
MessageDialog(
"error", f"{LocaleStrings.MSGA['error_gpg_check']}{e.stderr}"
).show()
return "Signature verification failed"
# Return the full path to the AppImage
return filename
except Exception as e:
from message import MessageDialog
MessageDialog(
"error", f"{LocaleStrings.MSGA['error_gpg_download']}{e}"
).show()
return None
class NetworkChecker: class NetworkChecker:
@staticmethod @staticmethod
def check_internet_connection(host="8.8.8.8", port=53, timeout=3): def check_internet_connection(
"""Check if internet connection is available""" host: str = "8.8.8.8", port: int = 53, timeout: float = 3
) -> bool:
"""
Checks if an internet connection is available.
Args:
host (str): Host to connect to for testing. Defaults to "8.8.8.8" (Google DNS).
port (int): Port number to use for the test. Defaults to 53.
timeout (float): Timeout in seconds for the connection attempt. Defaults to 3.
Returns:
bool: True if internet is available, False otherwise.
"""
try: try:
socket.setdefaulttimeout(timeout) socket.setdefaulttimeout(timeout)
socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port)) socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port))
@@ -31,10 +431,22 @@ class NetworkChecker:
return False return False
@staticmethod @staticmethod
def check_repository_access(url="https://git.ilunix.de", timeout=5): def check_repository_access(
"""Check if repository is accessible""" url: str = "https://git.ilunix.de", timeout: float = 5
) -> bool:
"""
Checks if the Gitea repository is accessible.
Args:
url (str): The URL of the Gitea repository. Defaults to "https://git.ilunix.de".
timeout (float): Timeout in seconds for the connection attempt. Defaults to 5.
Returns:
bool: True if the repository is accessible, False otherwise.
"""
try: try:
urllib.request.urlopen(url, timeout=timeout) urllib.request.urlopen(url, timeout=timeout)
return True return True
except: except Exception as e:
print(f"Error accessing Gitea repository: {e}")
return False return False

64
public_key.asc Normal file

@@ -0,0 +1,64 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBGhnC2QBEADiDYNLO6dmZFAeaVzFF8TcEI9EE/9Nf9R2aSapv0+GUOIyVnkS
tOeTDowAYUZLIlKEq2Vw+85PzjGg5YykzMVLGBnic6N2j7qYB92GsQYsU8En1op+
tGuayMMXWoGE29MnRhFhU7y7ObT0h/+P0TS66hhXXFQhCZ+ZaUa19J3SUEgOPXCn
Wk2gC0JaqtIwZAvVOYbZ3aoO9z7+DVJU/LKEYLu8Osa5t5U8Ox0QvGRG/eME+D8e
aI+dlGTDqf7Qq0sIlVoS+3pDpm2PANgA5B4uOhkLkY+BTfxOwTQlTjd8z4o2rAqH
RgVLQae9BBNEGZX3Mno+uu17jfKI+a+KpEcfdLdNDvvcWXCIh5D7U5ekjEmHvwYP
/dQTNcz2DIwCHLnshAN7Tls2HhD93Gtw+MJ9+C+Pq+uiBzN0zrPmfmPTH0og5Rby
b3SSiSgPlmVoRf2jedLhAn8evNtgC6rOPoSt2lX5wJVdVql2m2z8xqD2RO0tCra/
pxep5iyP/NuRHs4WLGRqyeeOJwy1J/tfylULD9dwj310gOH4fwhCxCFq7f9FtTew
yqxWcsF6wXZDVaBm0d73MBvjEGyfdPVbjHk06UdocRN6jmpC8wqn6mJ0r71Nq/ZE
55DnBBs54dvLRrSxmvLHUNQ0Wyq3BbnX6ILzSA25tVGTS4hI96b6znjRowARAQAB
tDFEw6lzaXLDqSBXZXJuZXIgTWVucmF0aCA8cG9sdW5nYTQwQHVuaXR5LW1haWwu
ZGU+iQJYBBMBCgBCFiEEdDdFCHxkFOAPHvhNTM8Gts4qTH8FAmhnC2QDGy8EBQkD
wmcABQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEEzPBrbOKkx/dZAQAJ7H
VhxYZ3LQiu/gRpc/VYcvV6zJiyU38lIWJ75EFhgI88rIGTmHYMk7juPVOeBHYCeI
ZgqpyYx69AJtFQukAgIeXFHgVBPVmxwbpzhUgvJNGmfT4CihruXDzZmwxtEJpZt8
DgC0BHAWw/xCTbMZrbKkpALZEQa79UnJNTymmqw1zGbrO9EaHj4UH1I0xX/xm8lc
Wo1H+CBWQXvzDFYeMb8bzZcwwYw30ZrxItSAO6Vg2jeopr0fdwMuEnqBGIRErtDD
ERn2EbbvVFMzbsw4UEs0xvsRGxRoCy7Wb6XlPdCon3FBZAMesrGOKa7K+7IrGrHC
mEhZzSl1atjM6MyJTWrdS4dobhaxicZlGkaub1cxuxnKYEV0l6H72nMs8QzjeSI6
jycppqF1xtGBGi1TBHqmgvb7In2HU/3jShRQzGs1hEOUTVv7ngeBxtirVFuK631W
MDfXnB6jMfkWCs3WVg9abxyU16vhMU5OK4tcXB1iT3MTOjiWUAONWDDur3uwBzsy
KWcXji+ak4a9vAnc/WaClTXyjhS78hxs4q2mDOMHSdjFiLN3Xr9hA1JzLdV+bxET
7d+TFQADjMAIAWpuIVErQOJEvMIFGLJiwMMhbtY/sFTHBnZv8GEfCGuUkoT+4E3U
TelMcvT0vhVycEicWxCsV3k1d2wdJfaT9xhsxW76uQINBGhnC2QBEAC7zDxkDO0y
B5+G0uRMGNuco1MdZI+BJdJk/+novYAVqc7wiwp4mmAj9XbMbHQB+8KBMo89CR2/
Uhi58bZKflcGS3iEU3nyYsRMkNrUdfxcuiT9yA0O2BVfhzo3zRseyYnsyx1Js6aF
rWPvR6T+aedYzEZaboNkyAvNI1Xbx6fb6glM8kPoBS5k3sxVD99jP/H19jDPDZCe
xPejbhuZGcBrpEFHaSno5MGSnmLmwV8hE81xLZFa5zwKwambW92TIFeAJpj9fsYp
RCtK0uvB7TY4lGgmenMEmFQ8f7tDhdgftrvoSM+WlrHxwErzsYbqlpTe/7Y16rbQ
q6FcRIUt5Gt/3IBWTlyifkobfRcV9O7OtpgOD4yqE7K/PFUF2d4JsVtKRHQGnU84
9cZVnG9nhgBfUu5/tm47W7R8t7lTWai4hpmbo/B+L/N2gzSCILuhYVft5a+fpyfS
ClMPqPFf0klcn4bYU0rGbAltdpk/6EW+DaC/RPPYxzvvokyZh5tOoAhcLmeR3Ise
XRCxrFZtDYdQ6ECXsA/Avao+LCjnW8dfKn/lcyU2glR/eFlIYxPTJPn6SLwWOHE9
qRZFBxvNdlPv1qEB7JVg3sliDRbhFKC8w6KfseQ/bBzfi+j+G7R1TRCO8Jj8VKkH
0Pztwlx9Qr73vXErff2/fhFwuUt/Nc5bKQARAQABiQRyBBgBCgAmFiEEdDdFCHxk
FOAPHvhNTM8Gts4qTH8FAmhnC2QCGy4FCQPCZwACQAkQTM8Gts4qTH/BdCAEGQEK
AB0WIQQnp1L/e8bCCVPMcFo8vK5CKLXhiQUCaGcLZAAKCRA8vK5CKLXhib+PEAC2
AciFSbLBTzHUyNISlcsNYMsqhek8eu4+h1h419eA4aNccCg387GcE8Vg7caWqYfT
qQlvhGBKnF1BDMqsHg5MKZe6BVE4f3Gx294vB+bxKc4avs0t+PjnkA0w0dDa5QTC
nRszCuNTXF+xd7x+OPTmFKUG8yNwS0csxnElzycbdC9KmImi3UjgaDPkuG81L1fK
8xqrZ6OVSW57fxNfwkdIUPxzRh4FNKnhhq7JyouvOq5lp8HEeqm8WwE2JhSCaZYC
qgevwt7ICEdDpOlq/BWUQJBtlsyS8pfyOsU/fSPWqywwpaJ5WdkvANw12z/IivKL
Avkq76DPm+6XP/ZHwrA/bVxoAHIGy5nJ5Rh1nQ2+piOf/jWtxk2lzypMDHwkaORN
eKWch95wlTt9X9GhaHkfpo7NVG1WZUMzEcHw30d7iEpWG/u60qW9k7X7YM/jf7E6
YY4husqmdENi5AwlQhRdxMHARrc6hkmLN0V6N+YOtINn5Rb15xrxpFNytwd+Vz1l
QeDKDnBEAOMLiev6cTujo06Hzc08bV+bzjSiDBz4aVkfE2NOxnNjPqjhQ/LbONXO
jQSLDiSmEXCjdAhZMfzsRT61V1TbLYKyxEa2AVrh4bHd3xpCIm2t1ZO11+Sj+LN+
5iZM8+ix7sDRzZZpse2UfofAzVpY7MLWymKAyMh1AW3mD/4llI1itkfj44A7OvjO
+DAsobGuDtOxMunJSNO4OnZp276fquF5gYaPYSRqrSOQLLXzBdzOGevTbSGWPoln
c3m4SPA+UbhWDMnIi65XCkxfAGg9/1cfTaHHTAdAxURHdJG1KoBi+IedP6UXxeZJ
OEPhp4NFQathlXvAJ05HdHfr6JOtQLyuMXv3IpsFXBeUWwe71n8b1R8Qn/lDLZk5
AJUborC80DztKL72HsatlhF5PZfhbKHucdsyRoduvLfa37xy0UrldVasBAIMsTSq
eaSVWf+ixj8Tz0o5qArwF/Lm9pSYRORTeLIOBYPoz/IERylW3dCIXrG4Op+Hloc1
UPvsj1acnH8gVFcRGROoZKxoMXmHmUQvnCcx604GZ0DtyRMzlxxykDyW/3PvPiZv
zaRYlLV1iXk+oPct9H2Aihnkm83oGumXYi3/2hjRm338W40l00LrQqqrdkAX/Mvo
oS495L3Q/z/PNx5X8G1fpRPZ6XqC/xGf+muEEwucAW3r6R+PmsyvbqWngxmRsEaX
jWBmlZGBIExcIAOUPMSSPc1GiVaEzR73ooJ9gHrWej357TqQPHG6qMflh+Inw/IZ
Y7O3AQRhJsrj7EsQ0xZb9r1aYCN39EZ2Yjik2SifCSExIhARg9fW3bV9ik7VUO2c
40WrGrMw8RQJRui1bNFIz4BT0w==
=G2Bo
-----END PGP PUBLIC KEY BLOCK-----