#!/usr/bin/env python3
#
# InspIRCd -- Internet Relay Chat Daemon
#
# Copyright (C) 2026 Sadie Powell <sadie@witchery.services>
#
# This file is part of InspIRCd. InspIRCd is free software: you can
# redistribute it and/or modify it under the terms of the GNU General Public
# License as published by the Free Software Foundation, version 2.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import argparse
import configparser
import hashlib
import json
import pathlib
import re
import sys
import urllib.request
from src.python import common, console, module
CONTRIB_MODULES = module.CONTRIB_DIR / "installed-modules.json"
CONTRIB_SOURCES = module.CONTRIB_DIR / "module-sources.ini"
def fetch_http(url):
try:
with urllib.request.urlopen(url) as response:
return response.read().decode("utf-8")
except (urllib.error.HTTPError, urllib.error.URLError, OSError) as e:
console.error(f"Unable to download {console.color(url, console.BOLD)}: {e}")
def fetch_modules(repository):
config = configparser.ConfigParser()
config.read(CONTRIB_SOURCES)
modules = {}
for section in config.sections():
if repository and repository not in section:
continue # Not what we're looking for.
repo = config[section]
if "url" not in repo:
console.warning(f"The {section} repository does not have an URL associated with it!")
continue
fetch_repository(section, repo, modules)
return modules
def fetch_repository(name, repository, modules):
repository_url = repository["url"]
console.debug(f"Fetching modules for {name} from {repository_url}")
data = fetch_http(repository_url)
if data:
module_config = None
for line in data.splitlines():
if match := re.fullmatch(r"\s*module (\S+) (\S+) (.+)", line, re.IGNORECASE):
module_name = match.group(1)
modules.setdefault(module_name, [])
module_config = {
"author": [],
"checksum": "",
"conflicts": {},
"depends": {},
"description": "",
"mask": "",
"name": module_name,
"repository": name,
"repository-desc": repository["description"],
"url": match.group(3),
"version": module.version_parse(match.group(2))
}
modules[module_name].append(module_config)
elif match := re.fullmatch(r"\s*author\s+(.+)", line, re.IGNORECASE):
module_config["author"].append(match.group(1))
elif match := re.fullmatch(r"\s*checksum\s+(\S+)", line, re.IGNORECASE):
module_config["checksum"] = match.group(1)
elif match := re.fullmatch(r"\s*conflicts\s+(\S+)(?:\s+(.+))", line, re.IGNORECASE):
module_config["conflicts"][match.group(1)] = match.group(2)
elif match := re.fullmatch(r"^\s*depends\s+(\S+)\s+(\d+(?:\.\d+)*)(?:-(\d+(?:\.\d+)*))?", line, re.IGNORECASE):
oldest_version = module.version_parse(match.group(2))
newest_version = module.version_parse(match.group(3)) if match.group(3) else oldest_version
module_config["depends"][match.group(1)] = (oldest_version, newest_version)
elif match := re.fullmatch(r"\s*description\s+(.+)", line, re.IGNORECASE):
module_config["description"] = match.group(1)
elif match := re.fullmatch(r"\s*mask\s+(.+)", line, re.IGNORECASE):
module_config["mask"] = match.group(1)
elif match := re.fullmatch(r"\s*source\s+(.+)", line, re.IGNORECASE):
fetch_repository(repository, match.group(1), modules)
elif line:
console.debug(f"Unknown line: {line}")
def find_module(modules, name, repository):
short_name = module.short_name(name)
selected_module = None
for module_data in modules.get(short_name, {}):
if repository and repository != module_data["repository"]:
continue
version = module_data["version"]
if not selected_module or module.version_compare(selected_module["version"], version) > 0:
console.debug(f"Potential remote module match: {name} {module.version_string(version)}")
selected_module = module_data
if selected_module:
console.debug(f"Remote dependency match: {name} {module.version_string(selected_module['version'])}")
return selected_module
def resolve_conflicts(dependencies, module_to_install):
module_to_install_name = module_to_install["name"]
for conflict_module, conflict_reason in module_to_install["conflicts"].items():
console.debug(f"The {module_to_install_name} conflicts with {conflict_module}: {conflict_reason}")
if conflict_module in dependencies:
console.error(f"The {module_to_install_name} module can not be installed whilst the {conflict_module} is installed: {conflict_reason}")
def resolve_dependencies(dependencies, installed_modules, remote_modules, module_to_install):
module_to_install_name = module_to_install["name"]
modules_to_install = []
for required_module, required_version in module_to_install["depends"].items():
requirement = required_module + " " + module.version_range_string(required_version)
console.debug(f"The {module_to_install_name} depends on {requirement}")
# Check for the dependency locally.
dependency = dependencies.get(required_module)
if dependency and module.version_in_range(dependency, required_version):
console.debug(f"Local dependency match: {required_module} {module.version_string(dependency)}")
continue
# Check for the dependency in the remotes.
remote_selection = None
for remote_module in remote_modules.get(required_module, []):
remote_version = remote_module["version"]
if not module.version_in_range(remote_version, required_version):
console.debug(f"Remote dependency not suitable: {required_module} {module.version_string(remote_version)}")
continue
# Check for a more recent potential dependency.
if not remote_selection or module.version_compare(remote_selection["version"], remote_version) > 0:
console.debug(f"Potential remote dependency match: {required_module} {module.version_string(remote_version)}")
remote_selection = remote_module
if not remote_selection:
console.error(f"Unable to find a module matching {console.color(requirement, console.BOLD)} in any remotes.")
console.debug(f"Remote dependency match: {required_module} {module.version_string(remote_selection['version'])}")
modules_to_install.append(remote_selection)
return modules_to_install
def install_module(dependencies, installed_modules, remote_modules, module_to_install, upgrade):
module_to_install_name = module_to_install["name"]
if module_to_install_name in dependencies and not upgrade:
console.debug(f"The {module_to_install_name} module is already installed.")
return
module_to_install_version = module.version_string(module_to_install["version"])
print(f"Installing version {console.color(module_to_install_version, console.BOLD)} of the {console.color(module_to_install_name, console.BOLD)} module ...")
resolve_conflicts(dependencies, module_to_install)
for module_dependency in resolve_dependencies(dependencies, installed_modules, remote_modules, module_to_install):
install_module(dependencies, installed_modules, remote_modules, module_dependency, upgrade)
try:
contents = fetch_http(module_to_install["url"])
actual_checksum = hashlib.sha256(contents.encode("utf-8")).hexdigest()
if "checksum" in module_to_install:
expected_checksum = module_to_install["checksum"]
if actual_checksum != expected_checksum:
console.error(
f"Checksum for the {console.color(module_to_install_name, console.BOLD)} module did not match, possibly corrupt module detected.",
f"Expected checksum: {expected_checksum}",
f"Actual checksum: {actual_checksum}",
)
else:
console.warning(f"The module source for {console.color(module_to_install_name, console.BOLD)} module did not specify a checksum, unable to detect corruption.")
with open(module.CONTRIB_DIR / module.file_name(module_to_install_name), "w", encoding="utf-8") as fh:
fh.write(contents)
dependencies[module_to_install_name] = module_to_install["version"]
installed_modules[module_to_install_name] = {
"checksum": actual_checksum,
"repository": module_to_install["repository"],
"version": module_to_install_version,
}
except OSError as e:
console.error(f"Unable to write module source: {e}")
def installed_dependencies(installed_modules):
dependencies = {}
# Core
core_version = common.version()
dependencies["core"] = (core_version["MAJOR"], core_version["MINOR"], core_version["PATCH"])
# Official modules
for long_module in module.DIR.iterdir():
if long_module.suffix == ".cpp":
short_name = module.short_name(long_module)
dependencies[short_name] = dependencies["core"]
# Installed modules
for module_name, module_data in installed_modules.items():
dependencies[module_name] = module.version_parse(module_data["version"])
return dependencies
def load_contrib_manifest():
try:
console.debug(f"Reading the contrib module manifest from {CONTRIB_MODULES} ...")
with open(CONTRIB_MODULES, "r", encoding="utf-8") as fh:
return json.load(fh)
except FileNotFoundError:
return {} # No contrib modules installed
except (OSError, json.JSONDecodeError) as e:
console.error(f"Unable to read contrib module manifest: {e}")
def save_contrib_manifest(installed_modules):
try:
console.debug(f"Writing the contrib module manifest to {CONTRIB_MODULES} ...")
with open(CONTRIB_MODULES, "w", encoding="utf-8") as fh:
json.dump(installed_modules, fh, indent=4)
except OSError as e:
console.error(f"Unable to write contrib module manifest: {e}")
def cmd_contrib(commands, args, start):
return console.subcommand(args, 2, {
"info": {
"function": cmd_contrib_info,
"help": "Install a module from a contrib repository",
"options": {
"--only-installed": "Only list modules that are installed",
"--repository REPO": "The repository to show modules from",
}
},
"install": {
"function": cmd_contrib_install,
"help": "Install a module from a contrib repository",
"options": {
"--repository REPO": "The repository to install modules from",
}
},
"list": {
"function": cmd_contrib_list,
"help": "List available contrib modules",
"options": {
"--only-installed": "Only list modules that are installed",
"--repository REPO": "The repository to list modules from",
"--show-masked": "Show modules that have been masked",
},
},
"uninstall": {
"function": cmd_contrib_uninstall,
"help": "Uninstall a module installed from a contrib repository",
},
"upgrade": {
"function": cmd_contrib_upgrade,
"help": "Update installed contrib modules",
},
# Helper aliases for common synonyms.
"update": {
"function": cmd_contrib_upgrade,
},
})
def cmd_contrib_info(commands, args, start):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--only-installed", help="Only list modules that are installed", action="store_true")
parser.add_argument("--repository", help="The repository to list modules from", metavar="REPO")
(options, modules_to_show) = parser.parse_known_args(args[start:])
if not modules_to_show:
console.error("You must specify one or more modules to show information about.")
installed_modules = load_contrib_manifest()
remote_modules = fetch_modules(options.repository)
for module_name in modules_to_show:
short_name = module.short_name(module_name)
if short_name not in remote_modules:
console.warning(f"The {console.color(short_name, console.BOLD)} module is not available.")
continue # Nothing to do
print(f"Information about the {console.color(short_name, console.BOLD)} module:")
print()
for idx, remote_module in enumerate(remote_modules[short_name]):
if idx:
print("---")
data = []
data.append(["Description", remote_module["description"]])
data.append(["Repository", f"{remote_module['repository-desc']} ({remote_module['repository']})"])
for author in remote_module["author"]:
data.append(["Author", author])
for conflict_module, conflict_reason in remote_module["conflicts"].items():
data.append(["Conflicts with", f"{conflict_module} ({conflict_reason})"])
for required_module, required_version in remote_module["depends"].items():
data.append(["Depends on", f"{required_module} {module.version_range_string(required_version)}"])
if remote_module["mask"]:
data.append(["Mask reason", remote_module['mask']])
if short_name in installed_modules:
installed_module = installed_modules[short_name]
if installed_module["repository"] == remote_module["repository"]:
match module.version_compare(module.version_parse(installed_module["version"]), remote_module["version"]):
case -1: # Outdated
data.append(["Status", "outdated"])
case 0: # Up-to-date
data.append(["Status", "up to date"])
case 1: # More recent than contrib
data.append(["Status", "more recent"])
else:
data.append(["Status", "installed from different repository"])
else:
data.append(["Status", "not installed"])
console.table(None, data, ":")
def cmd_contrib_install(commands, args, start):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--repository", help="The repository to list modules from", metavar="REPO")
(options, modules_to_install) = parser.parse_known_args(args[start:])
if not modules_to_install:
console.error("You must specify one or more modules to install.")
installed_modules = load_contrib_manifest()
dependencies = installed_dependencies(installed_modules)
remote_modules = fetch_modules(options.repository)
modules_installed = False
for module_name in modules_to_install:
short_name = module.short_name(module_name)
if short_name in installed_modules:
console.warning(f"The {console.color(short_name, console.BOLD)} module is already installed.")
continue # Nothing to do
remote_module = find_module(remote_modules, short_name, options.repository)
if not remote_module:
console.warning(f"The {console.color(short_name, console.BOLD)} module is not available in any repository.")
continue # Not available
install_module(dependencies, installed_modules, remote_modules, remote_module, False)
modules_installed = True
if modules_installed:
save_contrib_manifest(installed_modules)
def cmd_contrib_list(commands, args, start):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--only-installed", help="Only list modules that are installed", action="store_true")
parser.add_argument("--repository", help="The repository to list modules from", metavar="REPO")
parser.add_argument("--show-masked", help="Show modules that have been masked", action="store_true")
(options, _) = parser.parse_known_args(args[start:])
installed_modules = load_contrib_manifest()
remote_modules = fetch_modules(options.repository)
module_table = []
dependencies = installed_dependencies(installed_modules)
for module_name, remote_variants in sorted(remote_modules.items()):
for remote_module in remote_variants:
available_core = dependencies.get("core")
module_core = remote_module["depends"].get("core")
if not module.version_in_range(available_core, module_core):
continue # Module is not for this core version
installed_module = installed_modules.get(module_name)
version_string = module.version_string(remote_module["version"])
if installed_module:
if installed_module["repository"] == remote_module["repository"]:
match module.version_compare(module.version_parse(installed_module["version"]), remote_module["version"]):
case -1: # Outdated
version_string = console.color(version_string, console.BOLD_RED)
case 0: # Up-to-date
version_string = console.color(version_string, console.BOLD_GREEN)
case 1: # More recent than contrib
version_string = console.color(version_string, console.BOLD_YELLOW)
elif options.only_installed:
continue # Not installed
remote_mask = remote_module["mask"]
if remote_mask:
if options.show_masked:
module_name = console.color(module_name, console.FAINT)
else:
console.debug(f"Module {module_name} {version_string} from {remote_module['repository']} is masked: {remote_mask}")
continue
module_table.append([module_name, version_string, remote_module["description"]])
if not module_table:
console.error("No modules matching your requirements are available.")
console.table(["Module", "Version", "Description"], module_table)
def cmd_contrib_uninstall(commands, args, start):
if len(args) <= start:
console.error("You must specify one or more modules to uninstall.")
installed_modules = load_contrib_manifest()
for module_name in args[start:]:
short_name = module.short_name(module_name)
long_module = module.CONTRIB_DIR / module.file_name(module_name)
if not long_module.is_file():
console.error(f"The {console.color(short_name, console.BOLD)} module is not installed.")
elif short_name not in installed_modules:
console.error(f"The {console.color(short_name, console.BOLD)} module is not a contrib module.")
try:
print(f"Removing the {console.color(short_name, console.BOLD)} module ...")
long_module.unlink()
del installed_modules[short_name]
except OSError as e:
console.error(f"Unable to unlink {long_module}: {e}")
save_contrib_manifest(installed_modules)
def cmd_contrib_upgrade(commands, args, start):
installed_modules = load_contrib_manifest()
dependencies = installed_dependencies(installed_modules)
remote_modules = fetch_modules(None)
modules_to_upgrade = args[start:]
if not modules_to_upgrade:
modules_to_upgrade = list(installed_modules.keys())
modules_upgraded = False
for module_name in modules_to_upgrade:
short_name = module.short_name(module_name)
if short_name not in installed_modules:
console.error(f"The {console.color(short_name, console.BOLD)} module is not installed.")
installed_module = installed_modules[short_name]
remote_repository = installed_module["repository"]
remote_module = find_module(remote_modules, short_name, remote_repository)
if not remote_module:
console.warning(f"The {console.color(short_name, console.BOLD)} module is not available in the {remote_repository} repository.")
continue
installed_version = installed_module["version"]
remote_version = remote_module["version"]
if module.version_compare(module.version_parse(installed_version), remote_version) >= 0:
print(f"The {console.color(short_name, console.BOLD)} module is up to date.")
continue # Module up to date
try:
with open(module.CONTRIB_DIR / module.file_name(module_name), "rb") as fh:
file_checksum = hashlib.file_digest(fh, "sha256").hexdigest()
installed_checksum = installed_module["checksum"]
if installed_checksum != file_checksum:
console.warning(
f"Local checksum for the {console.color(short_name, console.BOLD)} module did not match; not updating.",
f"Expected checksum: {installed_checksum}",
f"Actual checksum: {file_checksum}",
)
continue # Locally modified
except OSError as e:
console.warning(f"Unable to checksum {short_name}: {e}")
print(f"Upgrading the {console.color(short_name, console.BOLD)} module from {console.color(installed_version, console.BOLD)} ...")
install_module(dependencies, installed_modules, remote_modules, remote_module, True)
modules_upgraded = True
if modules_upgraded:
save_contrib_manifest(installed_modules)
def cmd_extra(commands, args, start):
return console.subcommand(args, 2, {
"disable": {
"function": cmd_extra_disable,
"help": "Disable one or more extra modules",
},
"enable": {
"function": cmd_extra_enable,
"help": "Enable one or more extra modules",
},
"interactive": {
"function": cmd_extra_interactive,
"help": "Interactively enable or disable extra modules",
},
"list": {
"function": cmd_extra_list,
"help": "List the status of extra modules",
},
})
def cmd_extra_disable(commands, args, start):
if len(args) <= start:
console.error("You must specify one or more modules to disable.")
for module_name in args[start:]:
short_name = module.short_name(module_name)
long_module = module.DIR / module.file_name(module_name)
long_extra = module.EXTRA_DIR / module.file_name(module_name)
if not long_module.exists():
if not long_extra.exists():
console.error(f"The {console.color(short_name, console.BOLD)} module is not an extra module.")
elif long_module.is_symlink() and long_extra.readlink() != long_extra:
console.error(f"The {console.color(short_name, console.BOLD)} module is not a symlink to an extra module.")
else:
console.warning(f"The {console.color(short_name, console.BOLD)} module is not currently enabled.")
continue # Nothing to do.
try:
print(f"Disabling the {console.color(short_name, console.BOLD)} module ...")
long_module.unlink(long_extra)
except OSError as e:
console.error(f"Unable to unlink {short_name} from {long_extra}: {e}")
cmd_extra_list(commands, args, start)
def cmd_extra_enable(commands, args, start):
if len(args) <= start:
console.error("You must specify one or more modules to enable.")
for module_name in args[start:]:
short_name = module.short_name(module_name)
long_extra = module.EXTRA_DIR / module.file_name(module_name)
if not long_extra.exists():
console.error(f"{console.color(short_name, console.BOLD)} is not an extra module!")
long_module = module.DIR / module.file_name(module_name)
if long_module.is_symlink():
if long_module.readlink() == long_extra:
console.warning(f"The {console.color(short_name, console.BOLD)} module is already enabled.")
continue # Nothing to do
else:
long_module.unlink(missing_ok=True) # Remove the dead symlink.
try:
print(f"Enabling the {console.color(short_name, console.BOLD)} module ...")
long_module.symlink_to(long_extra)
except OSError as e:
console.error(f"Unable to symlink {short_name} to {long_extra}: {e}")
cmd_extra_list(commands, args, start)
def cmd_extra_interactive(commands, args, start):
if not console.interactive():
console.error("This command can not be used non-interactively!")
modules_to_enable = []
modules_to_disable = []
for module_name in sorted(module.EXTRA_DIR.glob("*.cpp")):
short_name = module.short_name(module_name)
print(f"Would you like to enable the {console.color(short_name, console.BOLD)} module?")
if console.prompt_boolean(False):
modules_to_enable.append(short_name)
else:
modules_to_disable.append(short_name)
for module_to_disable in modules_to_disable:
long_module = module.DIR / module.file_name(module_to_disable)
long_extra = module.EXTRA_DIR / module.file_name(module_to_disable)
if long_module.is_symlink() and long_module.readlink() == long_extra:
try:
console.debug(f"Disabling the {module_to_disable} module ...")
long_module.unlink()
except OSError as e:
console.warning(f"Unable to unlink {module_to_disable} from {long_extra}: {e}")
for module_to_enable in modules_to_enable:
long_module = module.DIR / module.file_name(module_to_enable)
long_extra = module.EXTRA_DIR / module.file_name(module_to_enable)
if not long_module.is_symlink() or long_module.readlink() != long_extra:
try:
console.debug(f"Enabling the {module_to_enable} module ...")
long_module.symlink_to(long_extra)
except OSError as e:
console.warning(f"Unable to symlink {module_to_enable} to {long_extra}: {e}")
cmd_extra_list(commands, args, start)
def cmd_extra_list(commands, args, start):
modules = []
for long_extra in sorted(module.EXTRA_DIR.iterdir()):
if not long_extra.suffix == ".cpp":
continue # Not a module
long_module = module.DIR / long_extra.name
short_module = module.short_name(long_extra)
if long_module.is_symlink():
if long_module.readlink() == long_extra:
modules.append([short_module, console.color("enabled", console.BOLD_GREEN)])
else:
modules.append([short_module, console.color("not an extra symlink", console.BOLD_YELLOW)])
elif long_module.exists():
modules.append([short_module, console.color("not a symlink", console.BOLD_RED)])
else:
modules.append([short_module, console.color("disabled", console.BOLD_YELLOW)])
console.table(["Name", "Status"], modules)
sys.exit(console.subcommand(sys.argv, 1, {
"contrib": {
"function": cmd_contrib,
"help": "Manipulate contrib modules",
},
"extra": {
"function": cmd_extra,
"help": "Manipulate extra modules",
},
# These commands exist for compatibility with v4 and earlier.
"install": {
"function": cmd_contrib_install,
},
"list": {
"function": cmd_contrib_list,
},
"upgrade": {
"function": cmd_contrib_upgrade,
},
}))