aboutsummaryrefslogtreecommitdiff
path: root/tools/cru-py/cru/service
diff options
context:
space:
mode:
Diffstat (limited to 'tools/cru-py/cru/service')
-rw-r--r--tools/cru-py/cru/service/_app.py4
-rw-r--r--tools/cru-py/cru/service/_config.py87
-rw-r--r--tools/cru-py/cru/service/_data.py9
-rw-r--r--tools/cru-py/cru/service/_docker.py19
-rw-r--r--tools/cru-py/cru/service/_external.py69
-rw-r--r--tools/cru-py/cru/service/_manager.py4
-rw-r--r--tools/cru-py/cru/service/_nginx.py227
-rw-r--r--tools/cru-py/cru/service/_template.py14
-rw-r--r--tools/cru-py/cru/service/nginx.py17
9 files changed, 340 insertions, 110 deletions
diff --git a/tools/cru-py/cru/service/_app.py b/tools/cru-py/cru/service/_app.py
index e72baec..6030dad 100644
--- a/tools/cru-py/cru/service/_app.py
+++ b/tools/cru-py/cru/service/_app.py
@@ -5,9 +5,9 @@ from ._base import (
PathCommandProvider,
)
from ._config import ConfigManager
-from ._data import DataManager
from ._template import TemplateManager
from ._nginx import NginxManager
+from ._external import CliToolCommandProvider
APP_ID = "crupest"
@@ -17,10 +17,10 @@ class App(AppBase):
super().__init__(APP_ID, f"{APP_ID}-service")
self.add_feature(PathCommandProvider())
self.add_feature(AppInitializer())
- self.add_feature(DataManager())
self.add_feature(ConfigManager())
self.add_feature(TemplateManager())
self.add_feature(NginxManager())
+ self.add_feature(CliToolCommandProvider())
self.add_feature(CommandDispatcher())
def run_command(self):
diff --git a/tools/cru-py/cru/service/_config.py b/tools/cru-py/cru/service/_config.py
index 52fed34..b51e21c 100644
--- a/tools/cru-py/cru/service/_config.py
+++ b/tools/cru-py/cru/service/_config.py
@@ -141,45 +141,46 @@ class ConfigManager(AppCommandFeatureProvider):
super().__init__("config-manager")
configuration = Configuration()
self._configuration = configuration
+ self._loaded: bool = False
self._init_app_defined_items()
def _init_app_defined_items(self) -> None:
prefix = self.config_name_prefix
- def _add_text(name: str, description: str) -> None:
- self.configuration.add(
- ConfigItem(f"{prefix}_{name}", description, TEXT_VALUE_TYPE)
- )
-
- def _add_uuid(name: str, description: str) -> None:
- self.configuration.add(
- ConfigItem(
- f"{prefix}_{name}",
- description,
- TEXT_VALUE_TYPE,
- default=UuidValueGenerator(),
- )
+ def _add_text(name: str, description: str) -> ConfigItem:
+ item = ConfigItem(f"{prefix}_{name}", description, TEXT_VALUE_TYPE)
+ self.configuration.add(item)
+ return item
+
+ def _add_uuid(name: str, description: str) -> ConfigItem:
+ item = ConfigItem(
+ f"{prefix}_{name}",
+ description,
+ TEXT_VALUE_TYPE,
+ default=UuidValueGenerator(),
)
+ self.configuration.add(item)
+ return item
def _add_random_string(
name: str, description: str, length: int = 32, secure: bool = True
- ) -> None:
- self.configuration.add(
- ConfigItem(
- f"{prefix}_{name}",
- description,
- TEXT_VALUE_TYPE,
- default=RandomStringValueGenerator(length, secure),
- )
+ ) -> ConfigItem:
+ item = ConfigItem(
+ f"{prefix}_{name}",
+ description,
+ TEXT_VALUE_TYPE,
+ default=RandomStringValueGenerator(length, secure),
)
+ self.configuration.add(item)
+ return item
- def _add_int(name: str, description: str) -> None:
- self.configuration.add(
- ConfigItem(f"{prefix}_{name}", description, INTEGER_VALUE_TYPE)
- )
+ def _add_int(name: str, description: str) -> ConfigItem:
+ item = ConfigItem(f"{prefix}_{name}", description, INTEGER_VALUE_TYPE)
+ self.configuration.add(item)
+ return item
- _add_text("DOMAIN", "domain name")
- _add_text("EMAIL", "admin email address")
+ self._domain = _add_text("DOMAIN", "domain name")
+ self._email = _add_text("EMAIL", "admin email address")
_add_text(
"AUTO_BACKUP_COS_SECRET_ID",
"access key id for Tencent COS, used for auto backup",
@@ -247,16 +248,18 @@ class ConfigManager(AppCommandFeatureProvider):
def get_item_value_str(self, name: str, ensure_set: bool = True) -> str | None: ...
def get_item_value_str(self, name: str, ensure_set: bool = True) -> str | None:
- self.reload_config_file()
+ self.load_config_file()
item = self.get_item(name)
- if ensure_set and not item.is_set:
- raise AppConfigItemNotSetError(
- f"Config item '{name}' is not set.", self.configuration, [item]
- )
+ if not item.is_set:
+ if ensure_set:
+ raise AppConfigItemNotSetError(
+ f"Config item '{name}' is not set.", self.configuration, [item]
+ )
+ return None
return item.value_str
def get_str_dict(self, ensure_all_set: bool = True) -> dict[str, str]:
- self.reload_config_file()
+ self.load_config_file()
if ensure_all_set and not self.configuration.all_set:
raise AppConfigItemNotSetError(
"Some config items are not set.",
@@ -265,8 +268,15 @@ class ConfigManager(AppCommandFeatureProvider):
)
return self.configuration.to_str_dict()
- def get_domain_item_name(self) -> str:
- return f"{self.config_name_prefix}_DOMAIN"
+ @property
+ def domain_item_name(self) -> str:
+ return self._domain.name
+
+ def get_domain_value_str(self) -> str:
+ return self.get_item_value_str(self._domain.name)
+
+ def get_email_value_str_optional(self) -> str | None:
+ return self.get_item_value_str(self._email.name, ensure_set=False)
def _set_with_default(self) -> None:
if not self.configuration.all_not_set:
@@ -379,7 +389,7 @@ class ConfigManager(AppCommandFeatureProvider):
value_dict = self._check_type(entry_dict)
return value_dict
- def reload_config_file(self):
+ def _real_load_config_file(self) -> None:
self.configuration.reset_all()
value_dict = self._read_config_file()
for key, value in value_dict.items():
@@ -387,6 +397,11 @@ class ConfigManager(AppCommandFeatureProvider):
continue
self.configuration.set_config_item(key, value)
+ def load_config_file(self, force=False) -> None:
+ if force or not self._loaded:
+ self._real_load_config_file()
+ self._loaded = True
+
def _print_app_config_info(self):
for item in self.configuration:
print(item.description_str)
diff --git a/tools/cru-py/cru/service/_data.py b/tools/cru-py/cru/service/_data.py
deleted file mode 100644
index 885c8e8..0000000
--- a/tools/cru-py/cru/service/_data.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from ._base import AppFeatureProvider
-
-
-class DataManager(AppFeatureProvider):
- def __init__(self) -> None:
- super().__init__("data-manager")
-
- def setup(self) -> None:
- pass
diff --git a/tools/cru-py/cru/service/_docker.py b/tools/cru-py/cru/service/_docker.py
deleted file mode 100644
index 9b801c4..0000000
--- a/tools/cru-py/cru/service/_docker.py
+++ /dev/null
@@ -1,19 +0,0 @@
-import subprocess
-
-from cru.tool import ExternalTool
-
-
-class DockerController(ExternalTool):
- DOCKER_BIN_NAME = "docker"
-
- def __init__(self, docker_bin: None | str = None) -> None:
- super().__init__(docker_bin or self.DOCKER_BIN_NAME)
-
- def list_containers(self) -> L[str]:
- p = subprocess.run(
- [self.docker_bin, "container", "ls", ""], capture_output=True
- )
- return p.stdout.decode("utf-8").splitlines()
-
- def restart_container(self, container_name: str) -> None:
- subprocess.run([self.docker_bin, "restart", container_name])
diff --git a/tools/cru-py/cru/service/_external.py b/tools/cru-py/cru/service/_external.py
new file mode 100644
index 0000000..418316a
--- /dev/null
+++ b/tools/cru-py/cru/service/_external.py
@@ -0,0 +1,69 @@
+from ._base import AppCommandFeatureProvider
+from ._nginx import NginxManager
+
+
+class CliToolCommandProvider(AppCommandFeatureProvider):
+ def __init__(self) -> None:
+ super().__init__("cli-tool-command-provider")
+
+ def setup(self):
+ pass
+
+ def get_command_info(self):
+ return ("gen-cli", "Get commands of running external cli tools.")
+
+ def setup_arg_parser(self, arg_parser):
+ subparsers = arg_parser.add_subparsers(
+ dest="gen_cli_command", required=True, metavar="GEN_CLI_COMMAND"
+ )
+ certbot_parser = subparsers.add_parser("certbot", help="print certbot commands")
+ certbot_parser.add_argument(
+ "-t", "--test", action="store_true", help="run certbot in test mode"
+ )
+ _install_docker_parser = subparsers.add_parser(
+ "install-docker", help="print docker commands"
+ )
+
+ def _print_install_docker_commands(self) -> None:
+ output = """
+### COMMAND: uninstall apt docker
+for pkg in docker.io docker-doc docker-compose \
+podman-docker containerd runc; \
+do sudo apt-get remove $pkg; done
+
+### COMMAND: prepare apt certs
+sudo apt-get update
+sudo apt-get install ca-certificates curl
+sudo install -m 0755 -d /etc/apt/keyrings
+
+### COMMAND: install certs
+sudo curl -fsSL https://download.docker.com/linux/debian/gpg \
+-o /etc/apt/keyrings/docker.asc
+sudo chmod a+r /etc/apt/keyrings/docker.asc
+
+### COMMAND: add docker apt source
+echo \\
+ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
+https://download.docker.com/linux/debian \\
+ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \\
+ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
+
+### COMMAND: update apt and install docker
+sudo apt-get update
+sudo apt-get install docker-ce docker-ce-cli containerd.io \
+docker-buildx-plugin docker-compose-plugin
+
+### COMMAND: setup system for docker
+sudo systemctl enable docker
+sudo systemctl start docker
+sudo groupadd -f docker
+sudo usermod -aG docker $USER
+# Remember to log out and log back in for the group changes to take effect
+""".strip()
+ print(output)
+
+ def run_command(self, args):
+ if args.gen_cli_command == "certbot":
+ self.app.get_feature(NginxManager).print_all_certbot_commands(args.test)
+ elif args.gen_cli_command == "install-docker":
+ self._print_install_docker_commands()
diff --git a/tools/cru-py/cru/service/_manager.py b/tools/cru-py/cru/service/_manager.py
deleted file mode 100644
index c1af428..0000000
--- a/tools/cru-py/cru/service/_manager.py
+++ /dev/null
@@ -1,4 +0,0 @@
-class CruServiceManager:
- "TODO: Continue here tomorrow!"
- def __init__(self):
- \ No newline at end of file
diff --git a/tools/cru-py/cru/service/_nginx.py b/tools/cru-py/cru/service/_nginx.py
index ad29d21..a9013e2 100644
--- a/tools/cru-py/cru/service/_nginx.py
+++ b/tools/cru-py/cru/service/_nginx.py
@@ -1,36 +1,55 @@
from argparse import Namespace
+from enum import Enum, auto
import re
+import subprocess
+from typing import TypeAlias
+
+from cru import CruInternalError
from ._base import AppCommandFeatureProvider
from ._config import ConfigManager
from ._template import TemplateManager
+class CertbotAction(Enum):
+ CREATE = auto()
+ EXPAND = auto()
+ SHRINK = auto()
+ RENEW = auto()
+
+
class NginxManager(AppCommandFeatureProvider):
+ CertbotAction: TypeAlias = CertbotAction
+
def __init__(self) -> None:
super().__init__("nginx-manager")
self._domains_cache: list[str] | None = None
- self._domain_config_value_cache: str | None = None
def setup(self) -> None:
pass
@property
+ def _config_manager(self) -> ConfigManager:
+ return self.app.get_feature(ConfigManager)
+
+ @property
+ def root_domain(self) -> str:
+ return self._config_manager.get_domain_value_str()
+
+ @property
def domains(self) -> list[str]:
if self._domains_cache is None:
self._domains_cache = self._get_domains()
return self._domains_cache
@property
- def _domain_config_name(self) -> str:
- return self.app.get_feature(ConfigManager).get_domain_item_name()
+ def subdomains(self) -> list[str]:
+ suffix = "." + self.root_domain
+ return [d[: -len(suffix)] for d in self.domains if d.endswith(suffix)]
- def _get_domain_config_value(self) -> str:
- if self._domain_config_value_cache is None:
- self._domain_config_value_cache = self.app.get_feature(
- ConfigManager
- ).get_item_value_str(self._domain_config_name)
- return self._domain_config_value_cache
+ @property
+ def _domain_config_name(self) -> str:
+ return self._config_manager.domain_item_name
def _get_domains_from_text(self, text: str) -> set[str]:
domains: set[str] = set()
@@ -42,17 +61,11 @@ class NginxManager(AppCommandFeatureProvider):
for match in regex.finditer(text):
domain_part = match.group(1)
if domain_variable_str in domain_part:
- domains.add(
- domain_part.replace(
- domain_variable_str, self._get_domain_config_value()
- )
- )
+ domains.add(domain_part.replace(domain_variable_str, self.root_domain))
continue
m = brace_domain_variable_regex.search(domain_part)
if m:
- domains.add(
- domain_part.replace(m.group(0), self._get_domain_config_value())
- )
+ domains.add(domain_part.replace(m.group(0), self.root_domain))
continue
domains.add(domain_part)
return domains
@@ -68,13 +81,123 @@ class NginxManager(AppCommandFeatureProvider):
def _get_domains(self) -> list[str]:
text = self._get_nginx_conf_template_text()
domains = list(self._get_domains_from_text(text))
- domains.remove(self._get_domain_config_value())
- return [self._get_domain_config_value(), *domains]
+ domains.remove(self.root_domain)
+ return [self.root_domain, *domains]
def _print_domains(self) -> None:
for domain in self.domains:
print(domain)
+ def _certbot_command(
+ self,
+ action: CertbotAction | str,
+ /,
+ test=False,
+ no_docker=False,
+ *,
+ standalone=None,
+ email=None,
+ agree_tos=True,
+ ) -> str:
+ if isinstance(action, str):
+ action = CertbotAction[action.upper()]
+
+ command_args = []
+
+ add_domain_option = True
+ if action is CertbotAction.CREATE:
+ if standalone is None:
+ standalone = True
+ command_action = "certonly"
+ elif action in [CertbotAction.EXPAND, CertbotAction.SHRINK]:
+ if standalone is None:
+ standalone = False
+ command_action = "certonly"
+ elif action is CertbotAction.RENEW:
+ if standalone is None:
+ standalone = False
+ add_domain_option = False
+ command_action = "renew"
+ else:
+ raise CruInternalError("Invalid certbot action.")
+
+ data_dir = self.app.data_dir.full_path.as_posix()
+
+ if no_docker:
+ command_args.append("certbot")
+ else:
+ command_args.extend(
+ [
+ "docker run -it --rm --name certbot",
+ f'-v "{data_dir}/certbot/certs:/etc/letsencrypt"',
+ f'-v "{data_dir}/certbot/data:/var/lib/letsencrypt"',
+ ]
+ )
+ if standalone:
+ command_args.append('-p "0.0.0.0:80:80"')
+ else:
+ command_args.append(f'-v "{data_dir}/certbot/webroot:/var/www/certbot"')
+
+ command_args.append("certbot/certbot")
+
+ command_args.append(command_action)
+
+ if standalone:
+ command_args.append("--standalone")
+ else:
+ command_args.append("--webroot -w /var/www/certbot")
+
+ if add_domain_option:
+ command_args.append(" ".join([f"-d {domain}" for domain in self.domains]))
+
+ if email is not None:
+ command_args.append(f"--email {email}")
+
+ if agree_tos:
+ command_args.append("--agree-tos")
+
+ if test:
+ command_args.append("--test-cert --dry-run")
+
+ return " ".join(command_args)
+
+ def print_all_certbot_commands(self, test: bool):
+ print("### COMMAND: (standalone) create certs")
+ print(
+ self._certbot_command(
+ CertbotAction.CREATE,
+ test,
+ email=self._config_manager.get_email_value_str_optional(),
+ )
+ )
+ print()
+ print("### COMMAND: (webroot+nginx) expand or shrink certs")
+ print(
+ self._certbot_command(
+ CertbotAction.EXPAND,
+ test,
+ email=self._config_manager.get_email_value_str_optional(),
+ )
+ )
+ print()
+ print("### COMMAND: (webroot+nginx) renew certs")
+ print(
+ self._certbot_command(
+ CertbotAction.RENEW,
+ test,
+ email=self._config_manager.get_email_value_str_optional(),
+ )
+ )
+
+ @property
+ def _cert_path_str(self) -> str:
+ return str(
+ self.app.data_dir.full_path
+ / "certbot/certs/live"
+ / self.root_domain
+ / "fullchain.pem"
+ )
+
def get_command_info(self):
return "nginx", "Manage nginx related things."
@@ -83,7 +206,73 @@ class NginxManager(AppCommandFeatureProvider):
dest="nginx_command", required=True, metavar="NGINX_COMMAND"
)
_list_parser = subparsers.add_parser("list", help="list domains")
+ certbot_parser = subparsers.add_parser("certbot", help="print certbot commands")
+ certbot_parser.add_argument(
+ "-t", "--test", action="store_true", help="run certbot in test mode"
+ )
def run_command(self, args: Namespace) -> None:
if args.nginx_command == "list":
self._print_domains()
+ elif args.nginx_command == "certbot":
+ self.print_all_certbot_commands(args.test)
+
+ def _generate_dns_zone(
+ self,
+ ip: str,
+ /,
+ ttl: str | int = 600,
+ *,
+ enable_mail: bool = True,
+ dkim: str | None = None,
+ ) -> str:
+ # TODO: Not complete and test now.
+ root_domain = self.root_domain
+ result = f"$ORIGIN {root_domain}.\n\n"
+ result += "; A records\n"
+ result += f"@ {ttl} IN A {ip}\n"
+ for subdomain in self.subdomains:
+ result += f"{subdomain} {ttl} IN A {ip}\n"
+
+ if enable_mail:
+ result += "\n; MX records\n"
+ result += f"@ {ttl} IN MX 10 mail.{root_domain}.\n"
+ result += "\n; SPF record\n"
+ result += f'@ {ttl} IN TXT "v=spf1 mx ~all"\n'
+ if dkim is not None:
+ result += "\n; DKIM record\n"
+ result += f'mail._domainkey {ttl} IN TEXT "{dkim}"'
+ result += "\n; DMARC record\n"
+ dmarc_options = [
+ "v=DMARC1",
+ "p=none",
+ f"rua=mailto:dmarc.report@{root_domain}",
+ f"ruf=mailto:dmarc.report@{root_domain}",
+ "sp=none",
+ "ri=86400",
+ ]
+ result += f'_dmarc {ttl} IN TXT "{"; ".join(dmarc_options)}"\n'
+ return result
+
+ def _get_dkim_from_mailserver(self) -> str | None:
+ # TODO: Not complete and test now.
+ dkim_path = (
+ self.app.data_dir.full_path
+ / "dms/config/opendkim/keys"
+ / self.root_domain
+ / "mail.txt"
+ )
+ if not dkim_path.exists():
+ return None
+
+ p = subprocess.run(["sudo", "cat", dkim_path], capture_output=True, check=True)
+ value = ""
+ for match in re.finditer('"(.*)"', p.stdout.decode("utf-8")):
+ value += match.group(1)
+ return value
+
+ def _generate_dns_zone_with_dkim(self, ip: str, /, ttl: str | int = 600) -> str:
+ # TODO: Not complete and test now.
+ return self._generate_dns_zone(
+ ip, ttl, enable_mail=True, dkim=self._get_dkim_from_mailserver()
+ )
diff --git a/tools/cru-py/cru/service/_template.py b/tools/cru-py/cru/service/_template.py
index 9241a1f..ca2135f 100644
--- a/tools/cru-py/cru/service/_template.py
+++ b/tools/cru-py/cru/service/_template.py
@@ -45,10 +45,10 @@ class TemplateManager(AppCommandFeatureProvider):
for file in CruIterator(self.template_tree.templates).transform(lambda t: t[0]):
print(file.as_posix())
- def _generate_files(self) -> None:
+ def _generate_files(self, dry_run: bool) -> None:
config_manager = self.app.get_feature(ConfigManager)
self.template_tree.generate_to(
- self.generated_dir.full_path_str, config_manager.get_str_dict()
+ self.generated_dir.full_path_str, config_manager.get_str_dict(), dry_run
)
def get_command_info(self):
@@ -62,7 +62,10 @@ class TemplateManager(AppCommandFeatureProvider):
_variables_parser = subparsers.add_parser(
"variables", help="list variables used in all templates"
)
- _generate_parser = subparsers.add_parser("generate", help="generate templates")
+ generate_parser = subparsers.add_parser("generate", help="generate templates")
+ generate_parser.add_argument(
+ "--no-dry-run", action="store_true", help="generate and write target files"
+ )
def run_command(self, args: Namespace) -> None:
if args.template_command == "list":
@@ -71,4 +74,7 @@ class TemplateManager(AppCommandFeatureProvider):
for var in self.template_tree.variables:
print(var)
elif args.template_command == "generate":
- self._generate_files()
+ dry_run = not args.no_dry_run
+ self._generate_files(dry_run)
+ if dry_run:
+ print("Dry run successfully.")
diff --git a/tools/cru-py/cru/service/nginx.py b/tools/cru-py/cru/service/nginx.py
deleted file mode 100644
index ad32cb9..0000000
--- a/tools/cru-py/cru/service/nginx.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import json
-import os
-import re
-import subprocess
-from typing import Literal, Any, cast, ClassVar
-
-
-
-def restart_nginx(force=False) -> bool:
- if not force:
- p = subprocess.run(['docker', "container", "ls",
- "-f", "name=nginx", "-q"], capture_output=True)
- container: str = p.stdout.decode("utf-8")
- if len(container.strip()) == 0:
- return False
- subprocess.run(['docker', 'restart', 'nginx'])
- return True