empeira 0.1.0.pre.alpha.1

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 (274) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +661 -0
  3. data/README.md +150 -0
  4. data/bin/empeira +18 -0
  5. data/config/defaults.yaml +229 -0
  6. data/docs/addons.md +331 -0
  7. data/docs/architecture.md +277 -0
  8. data/docs/configuration.md +1204 -0
  9. data/docs/development.md +472 -0
  10. data/docs/hiera-and-eyaml.md +361 -0
  11. data/docs/installation.md +280 -0
  12. data/docs/networking.md +362 -0
  13. data/docs/nodes.md +642 -0
  14. data/docs/proxy.md +136 -0
  15. data/docs/troubleshooting.md +361 -0
  16. data/lib/empeira/addons/environment.rb +82 -0
  17. data/lib/empeira/addons/error.rb +14 -0
  18. data/lib/empeira/addons/installation.rb +119 -0
  19. data/lib/empeira/addons/manifest.rb +112 -0
  20. data/lib/empeira/addons/service.rb +50 -0
  21. data/lib/empeira/addons/storage.rb +94 -0
  22. data/lib/empeira/agent/acquisition.rb +120 -0
  23. data/lib/empeira/agent/artifact.rb +41 -0
  24. data/lib/empeira/agent/authentication.rb +122 -0
  25. data/lib/empeira/agent/cache.rb +122 -0
  26. data/lib/empeira/agent/cache_storage.rb +82 -0
  27. data/lib/empeira/agent/download.rb +84 -0
  28. data/lib/empeira/agent/native_commands.rb +84 -0
  29. data/lib/empeira/agent/release_sources.rb +64 -0
  30. data/lib/empeira/agent/target.rb +66 -0
  31. data/lib/empeira/agent/versions.rb +29 -0
  32. data/lib/empeira/application.rb +86 -0
  33. data/lib/empeira/build.json +5 -0
  34. data/lib/empeira/build_info.rb +46 -0
  35. data/lib/empeira/cli/addons.rb +45 -0
  36. data/lib/empeira/cli/base.rb +73 -0
  37. data/lib/empeira/cli/completion.rb +42 -0
  38. data/lib/empeira/cli/config.rb +22 -0
  39. data/lib/empeira/cli/main.rb +114 -0
  40. data/lib/empeira/cli/node.rb +87 -0
  41. data/lib/empeira/cli/progress.rb +169 -0
  42. data/lib/empeira/cli/proxy.rb +36 -0
  43. data/lib/empeira/cli/update.rb +23 -0
  44. data/lib/empeira/completion/addons.rb +93 -0
  45. data/lib/empeira/completion/bash.rb +62 -0
  46. data/lib/empeira/completion/catalog.rb +170 -0
  47. data/lib/empeira/configuration/additional_services.rb +74 -0
  48. data/lib/empeira/configuration/addons.rb +85 -0
  49. data/lib/empeira/configuration/agent_schema.rb +163 -0
  50. data/lib/empeira/configuration/agent_values.rb +54 -0
  51. data/lib/empeira/configuration/bootstrap_guests.rb +42 -0
  52. data/lib/empeira/configuration/command_mocks.rb +65 -0
  53. data/lib/empeira/configuration/display.rb +20 -0
  54. data/lib/empeira/configuration/dns_rewrites.rb +58 -0
  55. data/lib/empeira/configuration/document.rb +67 -0
  56. data/lib/empeira/configuration/eyaml.rb +46 -0
  57. data/lib/empeira/configuration/hiera_modulepath.rb +40 -0
  58. data/lib/empeira/configuration/hiera_mount_schema.rb +62 -0
  59. data/lib/empeira/configuration/hiera_mounts.rb +83 -0
  60. data/lib/empeira/configuration/image_schema.rb +68 -0
  61. data/lib/empeira/configuration/loader.rb +70 -0
  62. data/lib/empeira/configuration/merge.rb +48 -0
  63. data/lib/empeira/configuration/network_redirects.rb +73 -0
  64. data/lib/empeira/configuration/node_images.rb +64 -0
  65. data/lib/empeira/configuration/schema.rb +215 -0
  66. data/lib/empeira/configuration/server_mounts.rb +106 -0
  67. data/lib/empeira/configuration/service_configuration.rb +40 -0
  68. data/lib/empeira/configuration/ssh_preferences.rb +83 -0
  69. data/lib/empeira/configuration/user.rb +46 -0
  70. data/lib/empeira/configuration/vm_interfaces.rb +147 -0
  71. data/lib/empeira/control_plane/additional_configurations.rb +106 -0
  72. data/lib/empeira/control_plane/browser.rb +73 -0
  73. data/lib/empeira/control_plane/controller.rb +763 -0
  74. data/lib/empeira/control_plane/discovery.rb +69 -0
  75. data/lib/empeira/control_plane/environment.rb +101 -0
  76. data/lib/empeira/control_plane/files.rb +263 -0
  77. data/lib/empeira/control_plane/health.rb +104 -0
  78. data/lib/empeira/control_plane/plan.rb +356 -0
  79. data/lib/empeira/control_plane/storage.rb +48 -0
  80. data/lib/empeira/errors.rb +9 -0
  81. data/lib/empeira/execution/console.rb +45 -0
  82. data/lib/empeira/execution/diagnostics.rb +210 -0
  83. data/lib/empeira/execution/launch_error.rb +14 -0
  84. data/lib/empeira/execution/result.rb +11 -0
  85. data/lib/empeira/execution/runner.rb +235 -0
  86. data/lib/empeira/execution/terminal.rb +35 -0
  87. data/lib/empeira/hostname_pattern.rb +14 -0
  88. data/lib/empeira/images/configuration.rb +44 -0
  89. data/lib/empeira/images/identity.rb +30 -0
  90. data/lib/empeira/images/recipe.rb +22 -0
  91. data/lib/empeira/images/reference.rb +43 -0
  92. data/lib/empeira/images/source.rb +19 -0
  93. data/lib/empeira/immutable.rb +19 -0
  94. data/lib/empeira/infrastructure/cleanup.rb +328 -0
  95. data/lib/empeira/infrastructure/definition.rb +59 -0
  96. data/lib/empeira/infrastructure/owned_files.rb +63 -0
  97. data/lib/empeira/infrastructure/recovery_inventory.rb +53 -0
  98. data/lib/empeira/infrastructure/service.rb +237 -0
  99. data/lib/empeira/infrastructure/status.rb +73 -0
  100. data/lib/empeira/infrastructure/store.rb +274 -0
  101. data/lib/empeira/modules/events.rb +67 -0
  102. data/lib/empeira/modules/git_safety.rb +60 -0
  103. data/lib/empeira/modules/host_git.rb +91 -0
  104. data/lib/empeira/modules/image.rb +27 -0
  105. data/lib/empeira/modules/installer.rb +67 -0
  106. data/lib/empeira/modules/request.rb +68 -0
  107. data/lib/empeira/modules/state.rb +87 -0
  108. data/lib/empeira/modules/storage.rb +35 -0
  109. data/lib/empeira/network/bootstrap_proxy.rb +157 -0
  110. data/lib/empeira/network/definition.rb +61 -0
  111. data/lib/empeira/network/direct_egress.rb +133 -0
  112. data/lib/empeira/network/egress.rb +27 -0
  113. data/lib/empeira/network/gateway.rb +79 -0
  114. data/lib/empeira/network/host_policy.rb +53 -0
  115. data/lib/empeira/network/naming.rb +28 -0
  116. data/lib/empeira/network/peer/adapter_image.rb +32 -0
  117. data/lib/empeira/network/peer/allocation.rb +45 -0
  118. data/lib/empeira/network/peer/attachment.rb +27 -0
  119. data/lib/empeira/network/peer/backend.rb +108 -0
  120. data/lib/empeira/network/peer/channel.rb +133 -0
  121. data/lib/empeira/network/peer/channel_server.rb +71 -0
  122. data/lib/empeira/network/peer/docker_adapter.rb +165 -0
  123. data/lib/empeira/network/peer/helper_security.rb +40 -0
  124. data/lib/empeira/network/peer/layout.rb +55 -0
  125. data/lib/empeira/network/peer/linux_podman.rb +114 -0
  126. data/lib/empeira/network/peer/podman_machine.rb +112 -0
  127. data/lib/empeira/network/policy.rb +39 -0
  128. data/lib/empeira/network/proxy_bindings.rb +52 -0
  129. data/lib/empeira/network/proxy_policy.rb +64 -0
  130. data/lib/empeira/network/redirects.rb +89 -0
  131. data/lib/empeira/network/resource.rb +50 -0
  132. data/lib/empeira/node/agent_installer.rb +31 -0
  133. data/lib/empeira/node/agent_package.rb +125 -0
  134. data/lib/empeira/node/agent_repository.rb +160 -0
  135. data/lib/empeira/node/apt_configuration.rb +85 -0
  136. data/lib/empeira/node/bootstrap.rb +17 -0
  137. data/lib/empeira/node/certificates.rb +98 -0
  138. data/lib/empeira/node/command_mock.rb +38 -0
  139. data/lib/empeira/node/command_mocks.rb +114 -0
  140. data/lib/empeira/node/container.rb +134 -0
  141. data/lib/empeira/node/container_agent_bootstrap.rb +43 -0
  142. data/lib/empeira/node/container_bootstrap.rb +52 -0
  143. data/lib/empeira/node/container_guest.rb +42 -0
  144. data/lib/empeira/node/container_lifecycle.rb +145 -0
  145. data/lib/empeira/node/container_preparation.rb +144 -0
  146. data/lib/empeira/node/container_state.rb +129 -0
  147. data/lib/empeira/node/definition.rb +20 -0
  148. data/lib/empeira/node/diagnostic_command.rb +19 -0
  149. data/lib/empeira/node/dnf_agent_repository.rb +202 -0
  150. data/lib/empeira/node/external_fact.rb +21 -0
  151. data/lib/empeira/node/image.rb +38 -0
  152. data/lib/empeira/node/interactive_tools.rb +33 -0
  153. data/lib/empeira/node/interface.rb +18 -0
  154. data/lib/empeira/node/inventory.rb +143 -0
  155. data/lib/empeira/node/package_bootstrap.rb +129 -0
  156. data/lib/empeira/node/package_proxy.rb +102 -0
  157. data/lib/empeira/node/package_sources.rb +19 -0
  158. data/lib/empeira/node/puppet_command.rb +12 -0
  159. data/lib/empeira/node/rpm_configuration.rb +62 -0
  160. data/lib/empeira/node/run_request.rb +52 -0
  161. data/lib/empeira/node/runtime_proxy.rb +86 -0
  162. data/lib/empeira/node/service.rb +72 -0
  163. data/lib/empeira/node/ssh_client.rb +25 -0
  164. data/lib/empeira/node/ssh_credentials.rb +78 -0
  165. data/lib/empeira/node/ssh_endpoint.rb +58 -0
  166. data/lib/empeira/node/ssh_identity.rb +37 -0
  167. data/lib/empeira/node/user_ssh.rb +60 -0
  168. data/lib/empeira/node/vm.rb +89 -0
  169. data/lib/empeira/node/vm_bootstrap.rb +53 -0
  170. data/lib/empeira/node/vm_lifecycle.rb +212 -0
  171. data/lib/empeira/node/vm_preflight.rb +37 -0
  172. data/lib/empeira/node/vm_preparation.rb +161 -0
  173. data/lib/empeira/node/vm_state.rb +161 -0
  174. data/lib/empeira/platform/facts.rb +66 -0
  175. data/lib/empeira/platform/kvm.rb +91 -0
  176. data/lib/empeira/platform/locations.rb +51 -0
  177. data/lib/empeira/platform/resolvers.rb +85 -0
  178. data/lib/empeira/platform/routes.rb +50 -0
  179. data/lib/empeira/progress.rb +70 -0
  180. data/lib/empeira/project_root.rb +47 -0
  181. data/lib/empeira/providers/lifecycle.rb +96 -0
  182. data/lib/empeira/providers/registry.rb +21 -0
  183. data/lib/empeira/providers/resource.rb +32 -0
  184. data/lib/empeira/providers.rb +55 -0
  185. data/lib/empeira/runtime/agent_helper.rb +34 -0
  186. data/lib/empeira/runtime/container.rb +184 -0
  187. data/lib/empeira/runtime/diagnostic_execution.rb +52 -0
  188. data/lib/empeira/runtime/docker.rb +87 -0
  189. data/lib/empeira/runtime/image_metadata.rb +83 -0
  190. data/lib/empeira/runtime/interface.rb +8 -0
  191. data/lib/empeira/runtime/mount_probe.rb +57 -0
  192. data/lib/empeira/runtime/podman.rb +122 -0
  193. data/lib/empeira/runtime/recipe_build.rb +103 -0
  194. data/lib/empeira/runtime/registry_diagnostics.rb +23 -0
  195. data/lib/empeira/runtime/service_arguments.rb +55 -0
  196. data/lib/empeira/runtime/service_images.rb +118 -0
  197. data/lib/empeira/runtime/service_inspection.rb +159 -0
  198. data/lib/empeira/runtime/services.rb +120 -0
  199. data/lib/empeira/runtime/update_helper.rb +97 -0
  200. data/lib/empeira/runtime/workspace_network.rb +69 -0
  201. data/lib/empeira/server/environment_configuration.rb +53 -0
  202. data/lib/empeira/server/http.rb +38 -0
  203. data/lib/empeira/server/puppetdb_runtime.rb +74 -0
  204. data/lib/empeira/server/relay_certificate.rb +76 -0
  205. data/lib/empeira/server/runtime.rb +90 -0
  206. data/lib/empeira/services/definition.rb +42 -0
  207. data/lib/empeira/updates/images.rb +104 -0
  208. data/lib/empeira/updates/modules.rb +45 -0
  209. data/lib/empeira/updates/service.rb +45 -0
  210. data/lib/empeira/version_requirement.rb +23 -0
  211. data/lib/empeira/vm/agent.rb +66 -0
  212. data/lib/empeira/vm/bootstrap_requirements.rb +69 -0
  213. data/lib/empeira/vm/certificate_runtime.rb +30 -0
  214. data/lib/empeira/vm/cloud_init.rb +171 -0
  215. data/lib/empeira/vm/disk.rb +136 -0
  216. data/lib/empeira/vm/guest.rb +123 -0
  217. data/lib/empeira/vm/guest_channel.rb +110 -0
  218. data/lib/empeira/vm/guest_network.rb +94 -0
  219. data/lib/empeira/vm/guest_output.rb +52 -0
  220. data/lib/empeira/vm/guest_shell.rb +79 -0
  221. data/lib/empeira/vm/guest_upload.rb +43 -0
  222. data/lib/empeira/vm/image_cache.rb +77 -0
  223. data/lib/empeira/vm/image_source.rb +91 -0
  224. data/lib/empeira/vm/interface.rb +8 -0
  225. data/lib/empeira/vm/interface_ownership.rb +124 -0
  226. data/lib/empeira/vm/interface_routes.rb +101 -0
  227. data/lib/empeira/vm/interfaces.rb +240 -0
  228. data/lib/empeira/vm/management.rb +73 -0
  229. data/lib/empeira/vm/prerequisites.rb +42 -0
  230. data/lib/empeira/vm/qemu.rb +83 -0
  231. data/lib/empeira/vm/qemu_runtime.rb +404 -0
  232. data/lib/empeira/vm/removal.rb +30 -0
  233. data/lib/empeira/vm/root_disk.rb +92 -0
  234. data/lib/empeira/vm/session_lock.rb +56 -0
  235. data/lib/empeira/vm/system_ssh.rb +29 -0
  236. data/lib/empeira/workspace.rb +42 -0
  237. data/lib/empeira.rb +209 -0
  238. data/resources/browser/relay.rb +22 -0
  239. data/resources/gateway/Containerfile +8 -0
  240. data/resources/gateway/bridge.rb +66 -0
  241. data/resources/gateway/configure.rb +323 -0
  242. data/resources/modules/Containerfile +15 -0
  243. data/resources/modules/Gemfile +5 -0
  244. data/resources/modules/Gemfile.lock +105 -0
  245. data/resources/modules/worker.rb +161 -0
  246. data/resources/network/adapter/Containerfile +13 -0
  247. data/resources/network/adapter/bridge_linux.go +51 -0
  248. data/resources/network/adapter/framing.go +45 -0
  249. data/resources/network/adapter/framing_test.go +53 -0
  250. data/resources/network/adapter/go.mod +3 -0
  251. data/resources/network/adapter/main_linux.go +273 -0
  252. data/resources/network/channel.rb +18 -0
  253. data/resources/network/ssh_connect.rb +32 -0
  254. data/resources/nodes/apt_sources.rb +66 -0
  255. data/resources/nodes/debian/Containerfile +10 -0
  256. data/resources/nodes/diagnostic_exec.rb +109 -0
  257. data/resources/nodes/interactive_tools.rb +139 -0
  258. data/resources/nodes/managed_file.rb +91 -0
  259. data/resources/nodes/management/guest_agent.py +415 -0
  260. data/resources/nodes/management/setup.sh +20 -0
  261. data/resources/nodes/management/shell_rc.sh +12 -0
  262. data/resources/nodes/management/system_account.sh +24 -0
  263. data/resources/nodes/management/system_home.sh +15 -0
  264. data/resources/nodes/rpm/Containerfile +10 -0
  265. data/resources/nodes/runtime_proxy.rb +244 -0
  266. data/resources/nodes/ssh/proxy.rb +16 -0
  267. data/resources/nodes/ssh/sshd_config +15 -0
  268. data/resources/nodes/ssh/start +6 -0
  269. data/resources/proxy/Containerfile +10 -0
  270. data/resources/relay/Containerfile +5 -0
  271. data/resources/relay/puppetdb.rb +81 -0
  272. data/resources/relay/server_environment.rb +10 -0
  273. data/resources/relay/server_start.sh +41 -0
  274. metadata +326 -0
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'pathname'
5
+ require 'tempfile'
6
+ require 'fileutils'
7
+
8
+ module Empeira
9
+ # Runs inside the owned guest using its agent Ruby; no separate guest inventory.
10
+ class ManagedFile
11
+ def initialize(request)
12
+ @path = Pathname(request.fetch('path'))
13
+ @content = request['content']
14
+ @accepted = request.fetch('accepted')
15
+ @mode = request.fetch('mode')
16
+ end
17
+
18
+ # rubocop:disable-next Naming/PredicateMethod -- Reconciliation mutates the managed file.
19
+ def reconcile
20
+ verify_parents!
21
+ current = verify_file!
22
+ return false if current.nil? && @content.nil?
23
+ return false if current && current == @content && (@path.stat.mode & 0o7777) == @mode
24
+
25
+ if @content.nil?
26
+ @path.unlink
27
+ else
28
+ write
29
+ end
30
+ true
31
+ end
32
+
33
+ private
34
+
35
+ def verify_parents!
36
+ @path.parent.ascend do |parent|
37
+ next unless parent.exist? || parent.symlink?
38
+ next if !parent.symlink? && parent.directory?
39
+
40
+ raise 'Managed file parent must be a directory, not a symlink'
41
+ end
42
+ end
43
+
44
+ def verify_file!
45
+ stat = @path.lstat
46
+ @content ? current_for_write(stat) : current_for_removal(stat)
47
+ rescue Errno::ENOENT
48
+ nil
49
+ end
50
+
51
+ def current_for_write(stat)
52
+ raise 'Command-mock target must be a file or symlink' unless stat.file? || stat.symlink?
53
+
54
+ return nil if stat.symlink? || stat.size != @content.bytesize
55
+
56
+ @path.binread
57
+ end
58
+
59
+ def current_for_removal(stat)
60
+ unless stat.file? && stat.nlink == 1 && stat.uid == Process.euid
61
+ raise 'Refusing to remove an unowned or modified command-mock target'
62
+ end
63
+
64
+ content = @path.binread
65
+ raise 'Refusing to remove an unowned or modified command-mock target' unless @accepted.include?(content)
66
+
67
+ content
68
+ end
69
+
70
+ def write
71
+ FileUtils.mkdir_p(@path.parent, mode: 0o755)
72
+ Tempfile.create('.empeira-', @path.parent) do |file|
73
+ file.chmod(@mode)
74
+ file.write(@content)
75
+ file.flush
76
+ file.fsync
77
+ File.rename(file.path, @path)
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ if $PROGRAM_NAME == __FILE__
84
+ begin
85
+ changed = Empeira::ManagedFile.new(JSON.parse(ARGV.fetch(0))).reconcile
86
+ puts JSON.generate('changed' => changed)
87
+ rescue StandardError => e
88
+ warn e.message
89
+ exit 1
90
+ end
91
+ end
@@ -0,0 +1,415 @@
1
+ #!/usr/bin/python3
2
+ # SPDX-License-Identifier: AGPL-3.0-only
3
+ """Private VirtIO-Serial execution adapter; no account authentication or network."""
4
+ import base64
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import select
9
+ import signal
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import stat
14
+ import tempfile
15
+ import time
16
+ import errno
17
+ import fcntl
18
+ import pty
19
+ import pwd
20
+ import struct
21
+ import termios
22
+
23
+ LIMIT = 131072
24
+ CHUNK = 16384
25
+ TOKEN = sys.argv[2]
26
+ PORT = sys.argv[1]
27
+ ROOT = '/run/empeira-management'
28
+ os.umask(0o077)
29
+ os.makedirs(ROOT, mode=0o700, exist_ok=True)
30
+ metadata = os.lstat(ROOT)
31
+ if not stat.S_ISDIR(metadata.st_mode) or metadata.st_uid != 0 or metadata.st_mode & 0o077:
32
+ raise RuntimeError('Management staging ownership is invalid')
33
+ if os.geteuid() != 0:
34
+ raise RuntimeError('Management requires numeric UID 0')
35
+
36
+
37
+ def interrupted(signum, _frame):
38
+ raise SystemExit(128 + signum)
39
+
40
+
41
+ for termination_signal in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
42
+ signal.signal(termination_signal, interrupted)
43
+
44
+
45
+ def supervise(ports):
46
+ # Independent bounded workers preserve the existing streaming/atomic-upload protocol.
47
+ # A blocked or failed session cannot stall another channel or ordinary Puppet execution.
48
+ workers = {}
49
+ try:
50
+ while True:
51
+ for port in ports:
52
+ worker = workers.get(port)
53
+ if worker is None or worker.poll() is not None:
54
+ workers[port] = subprocess.Popen([sys.executable, __file__, port, TOKEN])
55
+ time.sleep(0.5)
56
+ finally:
57
+ for worker in workers.values():
58
+ if worker.poll() is None:
59
+ worker.terminate()
60
+ for worker in workers.values():
61
+ try:
62
+ worker.wait(timeout=3)
63
+ except subprocess.TimeoutExpired:
64
+ worker.kill()
65
+ worker.wait()
66
+
67
+
68
+ if len(sys.argv) > 3:
69
+ supervise([PORT, *sys.argv[3:]])
70
+ raise SystemExit(0)
71
+
72
+ fd = os.open(PORT, os.O_RDWR | os.O_NONBLOCK)
73
+ events = select.epoll()
74
+ events.register(fd, select.EPOLLIN | select.EPOLLET | select.EPOLLHUP)
75
+ buffer = bytearray()
76
+ active = None
77
+ request_id = None
78
+
79
+
80
+ def send(data):
81
+ data['id'] = request_id
82
+ raw = (json.dumps(data, separators=(',', ':')) + '\n').encode()
83
+ if len(raw) > LIMIT:
84
+ raise ValueError('Oversized protocol frame')
85
+ deadline = time.monotonic() + 5
86
+ while raw:
87
+ if time.monotonic() >= deadline:
88
+ raise TimeoutError()
89
+ try:
90
+ written = os.write(fd, raw)
91
+ if written <= 0:
92
+ raise EOFError()
93
+ raw = raw[written:]
94
+ except BlockingIOError:
95
+ select.select([], [fd], [], 2)
96
+
97
+
98
+ def session_groups(pid):
99
+ groups = set()
100
+ for entry in os.scandir('/proc'):
101
+ if not entry.name.isdigit():
102
+ continue
103
+ try:
104
+ with open(entry.path + '/stat') as source:
105
+ fields = source.read().rpartition(') ')[2].split()
106
+ if fields[0] != 'Z' and int(fields[3]) == pid:
107
+ groups.add(int(fields[2]))
108
+ except (FileNotFoundError, ProcessLookupError):
109
+ continue
110
+ return groups
111
+
112
+
113
+ def terminate_groups(pid):
114
+ # The unreaped session leader pins the identity even when its shell created job-control groups.
115
+ groups = session_groups(pid)
116
+ if not groups:
117
+ return
118
+ for sig in (signal.SIGTERM, signal.SIGKILL):
119
+ for group in groups:
120
+ try:
121
+ os.killpg(group, sig)
122
+ except ProcessLookupError:
123
+ pass
124
+ if sig == signal.SIGTERM:
125
+ time.sleep(0.1)
126
+ groups = session_groups(pid)
127
+
128
+
129
+ def terminate():
130
+ global active
131
+ if not active:
132
+ return
133
+ if active['kind'] in ('exec', 'shell'):
134
+ process = active['process']
135
+ # The unreaped session leader pins the PID/process-group until cleanup.
136
+ terminate_groups(process.pid)
137
+ process.wait()
138
+ for pipe in active['pipes']:
139
+ if isinstance(pipe, int) and pipe not in active['open']:
140
+ continue
141
+ if not isinstance(pipe, int) and pipe.closed:
142
+ continue
143
+ try:
144
+ events.unregister(pipe if isinstance(pipe, int) else pipe.fileno())
145
+ except (FileNotFoundError, OSError):
146
+ pass
147
+ os.close(pipe) if isinstance(pipe, int) else pipe.close()
148
+ else:
149
+ active['file'].close()
150
+ os.unlink(active['path'])
151
+ active = None
152
+
153
+
154
+ def start_exec(message):
155
+ global active
156
+ argv = message['argv']
157
+ if not isinstance(argv, list) or not argv or not all(isinstance(s, str) and '\0' not in s for s in argv):
158
+ raise ValueError('Invalid command arguments')
159
+ try:
160
+ process = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
161
+ stderr=subprocess.PIPE, start_new_session=True,
162
+ env={**{key: value for key, value in os.environ.items() if key != 'RUNTIME_DIRECTORY'}, 'PATH': '/opt/puppetlabs/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'})
163
+ except OSError as error:
164
+ send({'type': 'stderr', 'data': base64.b64encode((os.strerror(error.errno) + '\n').encode()).decode()})
165
+ send({'type': 'exit', 'status': 127 if error.errno == 2 else 126, 'timed_out': False})
166
+ return
167
+ pipes = {process.stdout.fileno(): (process.stdout, 'stdout'),
168
+ process.stderr.fileno(): (process.stderr, 'stderr')}
169
+ active = {'kind': 'exec', 'process': process, 'pipes': [process.stdout, process.stderr],
170
+ 'open': pipes, 'deadline': time.monotonic() + message['timeout'] if message['timeout'] else None}
171
+ for pipe in pipes:
172
+ os.set_blocking(pipe, False)
173
+ events.register(pipe, select.EPOLLIN | select.EPOLLHUP)
174
+ send({'type': 'started'})
175
+
176
+
177
+ class ShellProcess:
178
+ def __init__(self, pid):
179
+ self.pid = pid
180
+
181
+ def wait(self):
182
+ _, status = os.waitpid(self.pid, 0)
183
+ return os.WEXITSTATUS(status) if os.WIFEXITED(status) else -os.WTERMSIG(status)
184
+
185
+
186
+ def terminal_size(message):
187
+ size = message.get('size')
188
+ if (not isinstance(size, list) or len(size) != 2 or
189
+ not all(type(value) is int and 0 < value <= 65535 for value in size)):
190
+ raise ValueError('Invalid terminal dimensions')
191
+ return struct.pack('HHHH', *size, 0, 0)
192
+
193
+
194
+ def command_environment():
195
+ return {**{key: value for key, value in os.environ.items() if key != 'RUNTIME_DIRECTORY'},
196
+ 'PATH': '/opt/puppetlabs/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'}
197
+
198
+
199
+ SHELL_RC = '/usr/local/libexec/empeira-management-shell-rc'
200
+
201
+
202
+ def start_shell(message):
203
+ global active
204
+ size = terminal_size(message)
205
+ term = message.get('term', 'xterm')
206
+ if not isinstance(term, str) or not 1 <= len(term) <= 64 or not all(c.isalnum() or c in '-_.+' for c in term):
207
+ raise ValueError('Invalid terminal type')
208
+ shell = shutil.which('bash') or shutil.which('sh')
209
+ if not shell:
210
+ raise ValueError('No guest shell is available')
211
+ pid, master = pty.fork()
212
+ if pid == 0:
213
+ try:
214
+ fcntl.ioctl(0, termios.TIOCSWINSZ, size)
215
+ try:
216
+ home = pwd.getpwuid(0).pw_dir
217
+ except KeyError:
218
+ home = '/'
219
+ os.chdir(home if os.path.isdir(home) else '/')
220
+ arguments = [shell, '--rcfile', SHELL_RC, '-i'] if os.path.basename(shell) == 'bash' else [shell, '-i']
221
+ os.execvpe(shell, arguments, {**command_environment(), 'HOME': home, 'HISTFILE': '/dev/null',
222
+ 'USER': 'root', 'LOGNAME': 'root', 'TERM': term})
223
+ except Exception:
224
+ os._exit(126)
225
+ os.set_blocking(master, False)
226
+ active = {'kind': 'shell', 'process': ShellProcess(pid), 'pipes': [master],
227
+ 'open': {master: (master, 'stdout')}, 'deadline': None,
228
+ 'input': bytearray(), 'master': master}
229
+ events.register(master, select.EPOLLIN | select.EPOLLHUP)
230
+ send({'type': 'started'})
231
+ send({'type': 'ready'})
232
+
233
+
234
+ def shell_input(message):
235
+ data = base64.b64decode(message['data'], validate=True)
236
+ if len(data) > CHUNK or active['input']:
237
+ raise ValueError('Shell input exceeds its granted credit')
238
+ active['input'].extend(data)
239
+ events.modify(active['master'], select.EPOLLIN | select.EPOLLOUT | select.EPOLLHUP)
240
+
241
+
242
+ def flush_shell_input():
243
+ master = active['master']
244
+ try:
245
+ written = os.write(master, active['input'])
246
+ del active['input'][:written]
247
+ except BlockingIOError:
248
+ return
249
+ if not active['input']:
250
+ events.modify(master, select.EPOLLIN | select.EPOLLHUP)
251
+ send({'type': 'ready'})
252
+
253
+
254
+ def handle(message):
255
+ global active, request_id
256
+ kind = message['type']
257
+ if kind == 'hello':
258
+ if message.get('token') != TOKEN:
259
+ raise ValueError('Instance identity mismatch')
260
+ if active:
261
+ terminate()
262
+ request_id = message['id']
263
+ send({'type': 'hello', 'token': TOKEN, 'version': 2})
264
+ return
265
+ if message['id'] != request_id:
266
+ raise ValueError('Request identity mismatch')
267
+ if kind == 'cancel':
268
+ terminate()
269
+ send({'type': 'exit', 'status': 130, 'timed_out': False})
270
+ elif kind == 'exec' and active is None:
271
+ timeout = message['timeout']
272
+ if timeout is not None and (type(timeout) not in (int, float) or not 0 < timeout <= 86400):
273
+ raise ValueError('Invalid operation timeout')
274
+ start_exec(message)
275
+ elif kind == 'shell' and active is None:
276
+ start_shell(message)
277
+ elif active and active['kind'] == 'shell' and kind == 'input':
278
+ shell_input(message)
279
+ elif active and active['kind'] == 'shell' and kind == 'resize':
280
+ fcntl.ioctl(active['master'], termios.TIOCSWINSZ, terminal_size(message))
281
+ elif active and active['kind'] == 'shell' and kind == 'eof':
282
+ terminate()
283
+ send({'type': 'exit', 'status': 0, 'timed_out': False})
284
+ elif active and active['kind'] == 'shell' and kind == 'signal':
285
+ allowed = {'INT': signal.SIGINT, 'TERM': signal.SIGTERM, 'HUP': signal.SIGHUP,
286
+ 'TSTP': signal.SIGTSTP, 'CONT': signal.SIGCONT}
287
+ sig = allowed.get(message.get('signal'))
288
+ group = os.tcgetpgrp(active['master'])
289
+ if sig is None or os.getsid(group) != active['process'].pid:
290
+ raise ValueError('Invalid shell foreground signal')
291
+ os.killpg(group, sig)
292
+ elif kind == 'upload' and active is None:
293
+ file = tempfile.NamedTemporaryFile(dir=ROOT, delete=False)
294
+ active = {'kind': 'upload', 'file': file, 'path': file.name, 'digest': hashlib.sha256(),
295
+ 'deadline': time.monotonic() + 120}
296
+ send({'type': 'ready'})
297
+ elif kind == 'chunk' and active and active['kind'] == 'upload':
298
+ data = base64.b64decode(message['data'], validate=True)
299
+ if len(data) > CHUNK:
300
+ raise ValueError('Oversized upload chunk')
301
+ active['file'].write(data)
302
+ active['digest'].update(data)
303
+ active['deadline'] = time.monotonic() + 120
304
+ send({'type': 'ready'})
305
+ elif kind == 'install' and active and active['kind'] == 'upload':
306
+ destination, mode = message['destination'], message['mode']
307
+ if not isinstance(destination, str) or not destination.startswith('/') or '\0' in destination:
308
+ raise ValueError('Invalid destination')
309
+ if not isinstance(mode, str) or len(mode) != 4 or any(c not in '01234567' for c in mode):
310
+ raise ValueError('Invalid mode')
311
+ if active['digest'].hexdigest() != message['sha256']:
312
+ raise ValueError('Upload integrity mismatch')
313
+ active['file'].flush()
314
+ os.fsync(active['file'].fileno())
315
+ staged = active['path']
316
+ active['file'].close()
317
+ active = None
318
+ pending = None
319
+ try:
320
+ with tempfile.NamedTemporaryFile(dir=os.path.dirname(destination), delete=False) as target:
321
+ pending = target.name
322
+ with open(staged, 'rb') as source:
323
+ shutil.copyfileobj(source, target, CHUNK)
324
+ target.flush()
325
+ os.fchmod(target.fileno(), int(mode, 8))
326
+ os.fsync(target.fileno())
327
+ os.replace(pending, destination)
328
+ pending = None
329
+ send({'type': 'exit', 'status': 0, 'timed_out': False})
330
+ except OSError as error:
331
+ send({'type': 'stderr', 'data': base64.b64encode((os.strerror(error.errno) + '\n').encode()).decode()})
332
+ send({'type': 'exit', 'status': 1, 'timed_out': False})
333
+ finally:
334
+ os.unlink(staged)
335
+ if pending:
336
+ os.unlink(pending)
337
+ else:
338
+ raise ValueError('Unexpected protocol operation')
339
+
340
+
341
+ def serial_input():
342
+ global buffer, request_id
343
+ while True:
344
+ try:
345
+ data = os.read(fd, CHUNK)
346
+ except BlockingIOError:
347
+ return
348
+ if not data:
349
+ terminate()
350
+ buffer.clear()
351
+ request_id = None
352
+ return
353
+ buffer.extend(data)
354
+ if len(buffer) > LIMIT:
355
+ raise ValueError('Oversized input frame')
356
+ while b'\n' in buffer:
357
+ line, _, remaining = buffer.partition(b'\n')
358
+ buffer = bytearray(remaining)
359
+ handle(json.loads(line))
360
+
361
+
362
+ def output(pipe):
363
+ stream, kind = active['open'][pipe]
364
+ try:
365
+ data = os.read(pipe, CHUNK)
366
+ except BlockingIOError:
367
+ return
368
+ except OSError as error:
369
+ if error.errno != errno.EIO or active['kind'] != 'shell':
370
+ raise
371
+ data = b''
372
+ if data:
373
+ send({'type': kind, 'data': base64.b64encode(data).decode()})
374
+ else:
375
+ events.unregister(pipe)
376
+ del active['open'][pipe]
377
+ os.close(stream) if isinstance(stream, int) else stream.close()
378
+
379
+
380
+ try:
381
+ while True:
382
+ timeout = -1 if not active else 0.1
383
+ for device, flags in events.poll(timeout):
384
+ if device == fd:
385
+ serial_input()
386
+ elif active and active['kind'] in ('exec', 'shell') and device in active['open']:
387
+ if flags & (select.EPOLLIN | select.EPOLLHUP):
388
+ output(device)
389
+ if active and active['kind'] == 'shell' and flags & select.EPOLLOUT and device in active['open']:
390
+ flush_shell_input()
391
+ if active and active['deadline'] is not None and time.monotonic() >= active['deadline']:
392
+ terminate()
393
+ send({'type': 'exit', 'status': None, 'timed_out': True})
394
+ elif (active and active['kind'] in ('exec', 'shell') and
395
+ os.waitid(os.P_PID, active['process'].pid, os.WEXITED | os.WNOHANG | os.WNOWAIT)):
396
+ # wait() reaps only after every output pipe reaches EOF; no PID reuse while cancelling.
397
+ terminate_groups(active['process'].pid)
398
+ if active['open']:
399
+ continue
400
+ status = active['process'].wait()
401
+ active = None
402
+ send({'type': 'exit', 'status': status if status >= 0 else 128 - status, 'timed_out': False})
403
+ except (BrokenPipeError, EOFError, OSError) as error:
404
+ terminate()
405
+ print('Management transport failure: ' + type(error).__name__, file=sys.stderr)
406
+ raise SystemExit(1)
407
+ except Exception as error:
408
+ terminate()
409
+ # Never log command arguments, input frames, keys or uploaded content.
410
+ print('Management protocol failure: ' + type(error).__name__, file=sys.stderr)
411
+ raise SystemExit(1)
412
+ finally:
413
+ terminate()
414
+ events.close()
415
+ os.close(fd)
@@ -0,0 +1,20 @@
1
+ #!/bin/sh
2
+ # Seed management and the ordinary SSH account once, before project bootstrap.
3
+ set -eu
4
+ fail() { echo "VM management setup failed: $1; inspect node shell and serial logs" >&2; exit 1; }
5
+ interpreter=
6
+ for candidate in /usr/bin/python3 /usr/libexec/platform-python; do
7
+ if test -x "$candidate" && "$candidate" -c 'import sys; sys.exit(sys.version_info < (3, 5))'; then
8
+ interpreter="$candidate"
9
+ break
10
+ fi
11
+ done
12
+ test -n "$interpreter" || fail 'the selected cloud image requires Python 3 before bootstrap'
13
+ test ! -e /usr/local/libexec/empeira-management-python && test ! -L /usr/local/libexec/empeira-management-python || fail 'preexisting management interpreter'
14
+ ln -s "$interpreter" /usr/local/libexec/empeira-management-python
15
+ @SYSTEM_SETUP@
16
+ if test -e /sys/fs/selinux/enforce; then
17
+ restorecon -R /usr/local/libexec/empeira-management-agent /etc/empeira/management /var/lib/empeira
18
+ fi
19
+ systemctl daemon-reload
20
+ systemctl enable --now empeira-management.service
@@ -0,0 +1,12 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Preserve interactive configuration and completion without persistent diagnostics history.
3
+ if [ -r /etc/bash.bashrc ]; then
4
+ . /etc/bash.bashrc
5
+ elif [ -r /etc/bashrc ]; then
6
+ . /etc/bashrc
7
+ fi
8
+ if [ -r "$HOME/.bashrc" ]; then
9
+ . "$HOME/.bashrc"
10
+ fi
11
+ HISTFILE=/dev/null
12
+ readonly HISTFILE
@@ -0,0 +1,24 @@
1
+ # Verify regular system SSH account metadata without exposing shadow contents or assigning a fixed UID/GID.
2
+ verify_account() {
3
+ empeira_account=$(getent passwd empeira) || fail 'missing system SSH account empeira'
4
+ empeira_uid=$(printf '%s\n' "$empeira_account" | cut -d : -f 3)
5
+ empeira_gid=$(printf '%s\n' "$empeira_account" | cut -d : -f 4)
6
+ empeira_home=$(printf '%s\n' "$empeira_account" | cut -d : -f 6)
7
+ empeira_shell=$(printf '%s\n' "$empeira_account" | cut -d : -f 7)
8
+ empeira_uid_min=$(awk '$1 == "UID_MIN" && $2 ~ /^[0-9]+$/ { print $2 }' /etc/login.defs)
9
+ for empeira_number in "$empeira_uid" "$empeira_gid" "$empeira_uid_min"; do
10
+ case "$empeira_number" in ''|*[!0-9]*) fail 'unverifiable system SSH UID/GID range' ;; esac
11
+ done
12
+ test "$empeira_uid" -gt 0 && test "$empeira_uid" -lt "$empeira_uid_min" || fail 'expected a dedicated system UID'
13
+ test "$empeira_gid" -gt 0 || fail 'system SSH account must not use the root group'
14
+ test "$empeira_home" = /var/lib/empeira || fail 'changed system SSH home; expected /var/lib/empeira'
15
+ test "$empeira_shell" = /bin/bash && test -x "$empeira_shell" || fail 'changed system SSH shell; expected /bin/bash'
16
+ empeira_shadow=$(getent shadow empeira) || fail 'cannot verify the system SSH password lock'
17
+ empeira_password=$(printf '%s\n' "$empeira_shadow" | cut -d : -f 2)
18
+ case "$empeira_password" in \!*|\**) ;; *) fail 'system SSH password must stay locked' ;; esac
19
+ unset empeira_shadow empeira_password empeira_account
20
+ test ! -L /var/lib/empeira || fail 'system SSH home is a symlink'
21
+ if test "$1" = creating && test ! -e /var/lib/empeira; then return; fi
22
+ test -d /var/lib/empeira || fail 'system SSH home is missing or not a directory'
23
+ test "$(stat -c '%u:%g:%a' /var/lib/empeira)" = "$empeira_uid:$empeira_gid:700" || fail 'system SSH home ownership or permissions changed'
24
+ }
@@ -0,0 +1,15 @@
1
+ # Cloud-init system users deliberately omit ssh_authorized_keys and home creation.
2
+ verify_account creating
3
+ if test ! -e /var/lib/empeira; then
4
+ install -d -m 0700 -o "$empeira_uid" -g "$empeira_gid" /var/lib/empeira
5
+ fi
6
+ verify_account verified
7
+ test ! -e /var/lib/empeira/.ssh && test ! -L /var/lib/empeira/.ssh || fail 'preexisting system SSH key directory'
8
+ install -d -m 0700 -o "$empeira_uid" -g "$empeira_gid" /var/lib/empeira/.ssh
9
+ install -m 0600 -o "$empeira_uid" -g "$empeira_gid" /etc/empeira/management/system_authorized_keys /var/lib/empeira/.ssh/authorized_keys
10
+ for empeira_profile in .bashrc .bash_profile .profile; do
11
+ if test -f "/etc/skel/$empeira_profile" && test ! -e "/var/lib/empeira/$empeira_profile"; then
12
+ test ! -L "/etc/skel/$empeira_profile" && test ! -L "/var/lib/empeira/$empeira_profile" || fail 'unsafe shell initialization path'
13
+ install -m 0600 -o "$empeira_uid" -g "$empeira_gid" "/etc/skel/$empeira_profile" "/var/lib/empeira/$empeira_profile"
14
+ fi
15
+ done
@@ -0,0 +1,10 @@
1
+ ARG EMPEIRA_BASE_NODE=@BASE_IMAGE@
2
+ FROM ${EMPEIRA_BASE_NODE}
3
+ RUN dnf install -y ca-certificates openssh-server systemd dbus iproute iputils bind-utils procps-ng sudo mlocate && dnf clean all
4
+ RUN command -v curl || dnf install -y curl
5
+ COPY node-start /usr/local/libexec/empeira-node-start
6
+ COPY node-connect /usr/local/libexec/empeira-ssh-connect.rb
7
+ COPY sshd_config /etc/ssh/sshd_config
8
+ RUN chmod 0755 /usr/local/libexec/empeira-node-start && rm -f /etc/ssh/ssh_host_* && systemctl enable sshd.service
9
+ ENTRYPOINT ["/usr/local/libexec/empeira-node-start"]
10
+ CMD ["/usr/sbin/sshd", "-D", "-e"]