nullforge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. nullforge/__init__.py +1 -0
  2. nullforge/foundry/README.md +0 -0
  3. nullforge/foundry/__init__.py +7 -0
  4. nullforge/foundry/full_cast.py +22 -0
  5. nullforge/inventories/README.md +0 -0
  6. nullforge/inventories/example.py +87 -0
  7. nullforge/models/__init__.py +1 -0
  8. nullforge/models/containers.py +60 -0
  9. nullforge/models/dns.py +222 -0
  10. nullforge/models/monitoring/__init__.py +29 -0
  11. nullforge/models/monitoring/base.py +19 -0
  12. nullforge/models/monitoring/nezha.py +13 -0
  13. nullforge/models/netsec.py +43 -0
  14. nullforge/models/profiles.py +33 -0
  15. nullforge/models/system.py +15 -0
  16. nullforge/models/users.py +9 -0
  17. nullforge/models/warp.py +65 -0
  18. nullforge/models/zerotrust.py +9 -0
  19. nullforge/molds/__init__.py +38 -0
  20. nullforge/molds/base_mold.py +59 -0
  21. nullforge/molds/containers.py +26 -0
  22. nullforge/molds/defaults.py +10 -0
  23. nullforge/molds/dns.py +70 -0
  24. nullforge/molds/features.py +75 -0
  25. nullforge/molds/haproxy.py +23 -0
  26. nullforge/molds/monitoring/__init__.py +45 -0
  27. nullforge/molds/monitoring/nezha.py +118 -0
  28. nullforge/molds/netsec.py +237 -0
  29. nullforge/molds/profiles.py +30 -0
  30. nullforge/molds/system.py +140 -0
  31. nullforge/molds/telemt.py +140 -0
  32. nullforge/molds/tor.py +28 -0
  33. nullforge/molds/user.py +77 -0
  34. nullforge/molds/utils.py +115 -0
  35. nullforge/molds/warp.py +54 -0
  36. nullforge/molds/xray.py +16 -0
  37. nullforge/molds/zerotrust.py +59 -0
  38. nullforge/runes/__init__.py +46 -0
  39. nullforge/runes/base.py +327 -0
  40. nullforge/runes/containers.py +212 -0
  41. nullforge/runes/dns.py +256 -0
  42. nullforge/runes/haproxy.py +135 -0
  43. nullforge/runes/monitoring.py +21 -0
  44. nullforge/runes/netsec.py +547 -0
  45. nullforge/runes/prepare.py +30 -0
  46. nullforge/runes/profiles.py +583 -0
  47. nullforge/runes/telemt.py +252 -0
  48. nullforge/runes/tor.py +57 -0
  49. nullforge/runes/users.py +159 -0
  50. nullforge/runes/warp.py +273 -0
  51. nullforge/runes/xray.py +81 -0
  52. nullforge/runes/zerotrust.py +91 -0
  53. nullforge/smithy/__init__.py +1 -0
  54. nullforge/smithy/admin.py +40 -0
  55. nullforge/smithy/arch.py +36 -0
  56. nullforge/smithy/blocky.py +37 -0
  57. nullforge/smithy/cloudflare.py +32 -0
  58. nullforge/smithy/github.py +256 -0
  59. nullforge/smithy/http.py +85 -0
  60. nullforge/smithy/install.py +149 -0
  61. nullforge/smithy/monitoring/__init__.py +1 -0
  62. nullforge/smithy/monitoring/nezha/__init__.py +0 -0
  63. nullforge/smithy/monitoring/nezha/agent.py +54 -0
  64. nullforge/smithy/monitoring/nezha/dashboard.py +137 -0
  65. nullforge/smithy/monitoring/nezha/deploy.py +81 -0
  66. nullforge/smithy/network.py +95 -0
  67. nullforge/smithy/packages.py +176 -0
  68. nullforge/smithy/service.py +37 -0
  69. nullforge/smithy/sni.py +358 -0
  70. nullforge/smithy/swap.py +132 -0
  71. nullforge/smithy/system.py +86 -0
  72. nullforge/smithy/versions.py +282 -0
  73. nullforge/templates/__init__.py +32 -0
  74. nullforge/templates/cloudflared/tunnel.yml.j2 +6 -0
  75. nullforge/templates/dns/blocky.yaml.j2 +11 -0
  76. nullforge/templates/dns/dns.yaml.j2 +6 -0
  77. nullforge/templates/dns/resolv.conf.j2 +1 -0
  78. nullforge/templates/dns/resolved.conf.j2 +13 -0
  79. nullforge/templates/etc/default/zramswap.j2 +13 -0
  80. nullforge/templates/nvim/nvim_patch.lua.j2 +5 -0
  81. nullforge/templates/profiles/direnv.toml +2 -0
  82. nullforge/templates/profiles/starship.toml +186 -0
  83. nullforge/templates/profiles/tmux.conf +32 -0
  84. nullforge/templates/profiles/zshrc.j2 +46 -0
  85. nullforge/templates/scripts/telemt-synfix.sh +89 -0
  86. nullforge/templates/scripts/teleproxy-warp.sh +127 -0
  87. nullforge/templates/scripts/warp-v6-policy.sh +118 -0
  88. nullforge/templates/scripts/zt-tunnel-warp.sh +28 -0
  89. nullforge/templates/systemd/blocky.service.j2 +19 -0
  90. nullforge/templates/systemd/cloudflare-tunnel.service.j2 +27 -0
  91. nullforge/templates/systemd/cloudflare-warp.service.j2 +21 -0
  92. nullforge/templates/systemd/dns-internal.service.j2 +15 -0
  93. nullforge/templates/systemd/telemt.service.j2 +36 -0
  94. nullforge/templates/telemt/telemt.toml.j2 +49 -0
  95. nullforge/templates/tor/torrc.j2 +5 -0
  96. nullforge-0.1.0.dist-info/METADATA +39 -0
  97. nullforge-0.1.0.dist-info/RECORD +99 -0
  98. nullforge-0.1.0.dist-info/WHEEL +4 -0
  99. nullforge-0.1.0.dist-info/licenses/LICENSE +7 -0
@@ -0,0 +1,81 @@
1
+ """Nezha agent deploy orchestration."""
2
+
3
+ from pyinfra.context import host
4
+ from pyinfra.facts.files import File
5
+ from pyinfra.facts.server import Hostname
6
+ from pyinfra.operations import python, server, systemd
7
+
8
+ from nullforge.molds.monitoring import NezhaBackend
9
+ from nullforge.molds.system import SystemMold
10
+ from nullforge.smithy.http import CURL_ARGS_STR
11
+ from nullforge.smithy.versions import STATIC_URLS
12
+
13
+ from .agent import agent_uuid_for_hostname, build_nezha_install_command
14
+ from .dashboard import rename_nezha_server, server_name_matches
15
+
16
+
17
+ def _resolve_hostname(cfg: NezhaBackend) -> str:
18
+ system: SystemMold = host.data.system
19
+ if isinstance(system.hostname, str) and system.hostname:
20
+ return system.hostname
21
+
22
+ fact = host.get_fact(Hostname)
23
+ if isinstance(fact, str) and fact:
24
+ return fact
25
+ return cfg.layout.systemd_service_name
26
+
27
+
28
+ def deploy_nezha(cfg: NezhaBackend) -> None:
29
+ """Deploy Nezha agent and align dashboard name with hostname."""
30
+
31
+ layout = cfg.layout
32
+ hostname = _resolve_hostname(cfg)
33
+ agent_uuid = agent_uuid_for_hostname(hostname, namespace=cfg.uuid_namespace)
34
+
35
+ if not host.get_fact(File, layout.config_path):
36
+ install_command = build_nezha_install_command(
37
+ install_url=STATIC_URLS["nezha_agent_install"],
38
+ curl_args=CURL_ARGS_STR,
39
+ server=cfg.server,
40
+ tls=cfg.tls,
41
+ client_secret=cfg.client_secret,
42
+ agent_uuid=agent_uuid,
43
+ disable_auto_update=cfg.disable_auto_update,
44
+ disable_command_execute=cfg.disable_command_execute,
45
+ )
46
+ server.shell(
47
+ name="Install Nezha agent",
48
+ commands=[install_command],
49
+ _sudo=True,
50
+ _retries=3,
51
+ _retry_delay=10,
52
+ )
53
+
54
+ systemd.service(
55
+ name="Ensure Nezha agent is running",
56
+ service=layout.systemd_service_name,
57
+ running=True,
58
+ enabled=True,
59
+ _sudo=True,
60
+ )
61
+
62
+ if not cfg.set_name_to_hostname:
63
+ return
64
+
65
+ if server_name_matches(
66
+ dashboard_url=cfg.dashboard_url,
67
+ api_token=cfg.api_token,
68
+ agent_uuid=agent_uuid,
69
+ desired_name=hostname,
70
+ ):
71
+ host.noop(f"Nezha dashboard already names {agent_uuid} '{hostname}'")
72
+ return
73
+
74
+ python.call(
75
+ name="Set Nezha dashboard name to hostname",
76
+ function=rename_nezha_server,
77
+ dashboard_url=cfg.dashboard_url,
78
+ api_token=cfg.api_token,
79
+ agent_uuid=agent_uuid,
80
+ desired_name=hostname,
81
+ )
@@ -0,0 +1,95 @@
1
+ """Network utilities for NullForge."""
2
+
3
+ import re
4
+
5
+ from pyinfra.context import host
6
+ from pyinfra.facts.files import FileContents
7
+ from pyinfra.facts.server import Command, KernelModules
8
+
9
+
10
+ def conntrack_max_for(ram_mb: int) -> int:
11
+ """Cap conntrack to ~2% of RAM as a backpressure ceiling (~300 B/entry)."""
12
+
13
+ entries = int(ram_mb * 1024 * 1024 * 0.02 / 300)
14
+ return max(16384, min(entries, 512 * 1024))
15
+
16
+
17
+ def module_loaded(module: str) -> bool:
18
+ """Return True if named kernel module is currently loaded."""
19
+
20
+ return module in _loaded_kernel_modules()
21
+
22
+
23
+ def _loaded_kernel_modules() -> dict[str, dict]:
24
+ cache_key = "_nullforge_kernel_modules"
25
+ if hasattr(host.data, cache_key):
26
+ return getattr(host.data, cache_key)
27
+
28
+ modules: dict[str, dict] = host.get_fact(KernelModules) or {}
29
+
30
+ setattr(host.data, cache_key, modules)
31
+ return modules
32
+
33
+
34
+ _TUNNEL_IFACE_RE = re.compile(
35
+ r"^(?:warp|tun\d*|tap\d*|wg\d*|zt[0-9a-f]*|tailscale|ts\d|"
36
+ r"docker\d*|br-[0-9a-f]+|veth[0-9a-f]+|cni\d*|cali[0-9a-f]+|"
37
+ r"flannel|weave|ipip|sit|gre|ip6tnl|ip6gre)"
38
+ )
39
+
40
+
41
+ def _ipv6_grub_disabled() -> bool:
42
+ """Return True if GRUB is configured with ipv6.disable=1."""
43
+
44
+ cache_key = "_nullforge_ipv6_grub_disabled"
45
+ if hasattr(host.data, cache_key):
46
+ return getattr(host.data, cache_key)
47
+
48
+ grub_contents = host.get_fact(FileContents, path="/etc/default/grub") or []
49
+ disabled = any("ipv6.disable=1" in line for line in grub_contents)
50
+
51
+ setattr(host.data, cache_key, disabled)
52
+ return disabled
53
+
54
+
55
+ def _ipv6_global_addrs_raw() -> str:
56
+ cache_key = "_nullforge_ipv6_global_raw"
57
+ if hasattr(host.data, cache_key):
58
+ return getattr(host.data, cache_key)
59
+
60
+ raw = host.get_fact(Command, "ip -6 -o addr show scope global 2>/dev/null || true") or ""
61
+ setattr(host.data, cache_key, raw)
62
+ return raw
63
+
64
+
65
+ def has_ipv6(*, exclude_iface: str | None = None) -> bool:
66
+ """Return whether the host has usable global IPv6."""
67
+
68
+ if exclude_iface is None:
69
+ cache_key = "_nullforge_ipv6_enabled"
70
+ if hasattr(host.data, cache_key):
71
+ return getattr(host.data, cache_key)
72
+
73
+ if _ipv6_grub_disabled():
74
+ setattr(host.data, "_nullforge_ipv6_enabled", False)
75
+ return False
76
+
77
+ raw = _ipv6_global_addrs_raw()
78
+
79
+ result = False
80
+ for line in raw.splitlines():
81
+ parts = line.split(maxsplit=3)
82
+ if len(parts) < 3:
83
+ continue
84
+ iface = parts[1].rstrip(":")
85
+ if exclude_iface and iface == exclude_iface:
86
+ continue
87
+ if _TUNNEL_IFACE_RE.match(iface):
88
+ continue
89
+ if parts[2] == "inet6":
90
+ result = True
91
+ break
92
+
93
+ if exclude_iface is None:
94
+ setattr(host.data, "_nullforge_ipv6_enabled", result)
95
+ return result
@@ -0,0 +1,176 @@
1
+ """Package management utilities for NullForge."""
2
+
3
+ from typing import TYPE_CHECKING, Any, Protocol, cast
4
+
5
+ from pyinfra.context import host
6
+ from pyinfra.facts.server import LinuxDistribution
7
+ from pyinfra.operations import apt, dnf
8
+
9
+
10
+ if TYPE_CHECKING:
11
+ from pyinfra.api.host import Host
12
+
13
+ UBUNTU_OVERRIDES = {
14
+ "ifupdown2": "ifupdown",
15
+ }
16
+ """Specific overrides for Ubuntu"""
17
+
18
+ RHEL_OVERRIDES = {
19
+ "aha": None,
20
+ "apt-transport-https": None,
21
+ "bat": "bat",
22
+ "bind9-host": "bind-utils",
23
+ "build-essential": "@Development Tools",
24
+ "dnsutils": "bind-utils",
25
+ "g++": "gcc-c++",
26
+ "gnupg": "gnupg2",
27
+ "ifupdown2": None,
28
+ "iputils-ping": "iputils",
29
+ "libevent-dev": "libevent-devel",
30
+ "libnss-resolve": "systemd-resolved",
31
+ "libssl-dev": "openssl-devel",
32
+ "locales": "glibc-langpack-en",
33
+ "mtr-tiny": "mtr",
34
+ "ncat": "nmap-ncat",
35
+ "ncurses-dev": "ncurses-devel",
36
+ "pkg-config": "pkgconfig",
37
+ "python3-dev": "python3-devel",
38
+ "software-properties-common": None,
39
+ "ufw": "firewalld",
40
+ }
41
+ """Package overrides for RHEL/CentOS/Fedora families
42
+ Key: Canonical name (Debian/Ubuntu style)
43
+ Value: RHEL equivalent (or None to skip)
44
+ """
45
+
46
+
47
+ class SystemPackageModule(Protocol):
48
+ """Protocol for pyinfra package manager modules (apt, dnf, etc)."""
49
+
50
+ def packages(self, packages: list[str] | None = None, **kwargs: Any) -> Any: ...
51
+ def update(self, **kwargs: Any) -> Any: ...
52
+
53
+
54
+ class AptModule(SystemPackageModule, Protocol):
55
+ """Protocol for apt module which includes upgrade."""
56
+
57
+ def upgrade(self, **kwargs: Any) -> Any: ...
58
+
59
+
60
+ class PackageManager:
61
+ def __init__(self, host: "Host"):
62
+ self.host = host
63
+ self.distro_info = host.get_fact(LinuxDistribution)
64
+ self.distro_name = (self.distro_info.get("name") or "").lower()
65
+ self.distro_major = self.distro_info.get("major", 0) or 0
66
+
67
+ @property
68
+ def is_debian_based(self) -> bool:
69
+ return any(x in self.distro_name for x in ["debian", "ubuntu"])
70
+
71
+ @property
72
+ def is_rhel_based(self) -> bool:
73
+ return any(x in self.distro_name for x in ["centos", "redhat", "rhel", "fedora", "rocky", "alma"])
74
+
75
+ @property
76
+ def is_ubuntu(self) -> bool:
77
+ return "ubuntu" in self.distro_name
78
+
79
+ @property
80
+ def is_debian(self) -> bool:
81
+ return "debian" in self.distro_name
82
+
83
+ def _get_module(self) -> SystemPackageModule:
84
+ if self.is_debian_based:
85
+ return cast(SystemPackageModule, apt)
86
+ elif self.is_rhel_based:
87
+ return cast(SystemPackageModule, dnf)
88
+ else:
89
+ raise NotImplementedError(f"Unsupported distribution: {self.distro_name}")
90
+
91
+ def map_package(self, package: str) -> str | None:
92
+ """Map a package name to the current distro's equivalent."""
93
+
94
+ if self.is_ubuntu and package in UBUNTU_OVERRIDES:
95
+ return UBUNTU_OVERRIDES[package]
96
+
97
+ if self.is_debian_based:
98
+ return package
99
+
100
+ if self.is_rhel_based and package in RHEL_OVERRIDES:
101
+ return RHEL_OVERRIDES[package]
102
+
103
+ # TODO: Maybe make better fallback mechanism
104
+ return package
105
+
106
+ def map_packages(self, packages: list[str]) -> list[str]:
107
+ """Map a list of packages."""
108
+
109
+ mapped = set()
110
+ for p in packages:
111
+ m = self.map_package(p)
112
+ if m:
113
+ mapped.add(m)
114
+ return list(mapped)
115
+
116
+ def update(self, **kwargs: Any) -> Any:
117
+ """Refresh the package repository cache.
118
+
119
+ On Debian/Ubuntu: runs `apt update`.
120
+ On RHEL/Fedora: no-op — dnf has no separate cache refresh step;
121
+ `dnf upgrade` fetches fresh metadata automatically.
122
+ """
123
+
124
+ mod = self._get_module()
125
+ if self.is_debian_based:
126
+ return mod.update(**kwargs)
127
+ return None
128
+
129
+ def upgrade(self, **kwargs: Any) -> Any:
130
+ """Upgrade all installed packages.
131
+
132
+ On Debian/Ubuntu: runs `apt upgrade`.
133
+ On RHEL/Fedora: runs `dnf upgrade`, which also refreshes metadata.
134
+ """
135
+
136
+ call_kwargs = kwargs.copy()
137
+ mod = self._get_module()
138
+
139
+ if not self.is_debian_based:
140
+ call_kwargs.pop("auto_remove", None)
141
+
142
+ if self.is_debian_based:
143
+ return cast(AptModule, mod).upgrade(**call_kwargs)
144
+ elif self.is_rhel_based:
145
+ return mod.update(**call_kwargs)
146
+ return None
147
+
148
+ def install(self, packages: list[str], **kwargs: Any) -> Any:
149
+ """Install packages."""
150
+
151
+ mod = self._get_module()
152
+ mapped_packages = self.map_packages(packages)
153
+ if not mapped_packages:
154
+ return None
155
+
156
+ call_kwargs = kwargs.copy()
157
+
158
+ # Remove apt-specific kwargs if not apt
159
+ if not self.is_debian_based:
160
+ call_kwargs.pop("no_recommends", None)
161
+ call_kwargs.pop("force", None)
162
+ call_kwargs.pop("cache_time", None)
163
+ call_kwargs.pop("extra_uninstall_args", None)
164
+
165
+ return mod.packages(packages=mapped_packages, **call_kwargs)
166
+
167
+
168
+ def get_pm() -> PackageManager:
169
+ """Get or create the PackageManager for the current host."""
170
+
171
+ if hasattr(host.data, "_nullforge_package_manager"):
172
+ return getattr(host.data, "_nullforge_package_manager")
173
+
174
+ pm = PackageManager(host)
175
+ setattr(host.data, "_nullforge_package_manager", pm)
176
+ return pm
@@ -0,0 +1,37 @@
1
+ """Shared helpers for provisioning system service users and config directories."""
2
+
3
+ from pyinfra.context import host
4
+ from pyinfra.facts.server import Groups, Users
5
+ from pyinfra.operations import files, server
6
+
7
+
8
+ def ensure_service_user(user: str, group: str, config_dir: str) -> None:
9
+ """Ensure a system service user, group, and config directory exist."""
10
+
11
+ if group not in host.get_fact(Groups):
12
+ server.group(
13
+ name=f"Ensure {group} group exists",
14
+ group=group,
15
+ system=True,
16
+ _sudo=True,
17
+ )
18
+
19
+ if user not in host.get_fact(Users):
20
+ server.user( # noqa: S604
21
+ name=f"Ensure {user} system user exists",
22
+ user=user,
23
+ system=True,
24
+ group=group,
25
+ shell="/bin/false",
26
+ create_home=False,
27
+ _sudo=True,
28
+ )
29
+
30
+ files.directory(
31
+ name=f"Ensure {config_dir} configuration directory exists",
32
+ path=config_dir,
33
+ user=user,
34
+ group=group,
35
+ mode="0755",
36
+ _sudo=True,
37
+ )