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,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+ require 'socket'
5
+
6
+ module Empeira
7
+ module Network
8
+ module DirectEgress
9
+ DNS_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?'
10
+ DNS_NAME = /\A#{DNS_LABEL}(?:\.#{DNS_LABEL})*\z/
11
+ MAX_HOSTS = 256
12
+ BENCHMARK = IPAddr.new('198.18.0.0/15').freeze
13
+
14
+ Resolved = Data.define(:entries) do
15
+ def fingerprint
16
+ Infrastructure::Definition.fingerprint(entries)
17
+ end
18
+
19
+ def configuration
20
+ JSON.generate('version' => 1, 'entries' => entries)
21
+ end
22
+ end
23
+
24
+ module_function
25
+
26
+ def validate!(value, path = 'network.egress')
27
+ return legacy!(value, path) if value.is_a?(Hash)
28
+ unless value.is_a?(Array) && value.size <= MAX_HOSTS
29
+ raise ConfigurationError, "#{path} must be an array with at most #{MAX_HOSTS} destination entries"
30
+ end
31
+
32
+ hosts = value.each_with_index.map { |entry, index| validate_entry!(entry, "#{path}[#{index}]") }
33
+ raise ConfigurationError, "#{path} contains duplicate destinations" unless hosts.uniq.size == hosts.size
34
+ end
35
+
36
+ def legacy!(value, path)
37
+ return if value.keys == ['mode'] && %w[none proxy].include?(value['mode'])
38
+
39
+ raise ConfigurationError, "#{path} must be a host array (legacy mode accepts only none or proxy)"
40
+ end
41
+
42
+ def destinations(value)
43
+ return [] unless value.is_a?(Array)
44
+
45
+ value.map { |entry| entry.fetch('host') { entry.fetch('ip') } }
46
+ end
47
+
48
+ def validate_entry!(entry, path)
49
+ unless entry.is_a?(Hash) && [%w[host ports], %w[ip ports]].include?(entry.keys.sort)
50
+ raise ConfigurationError, "#{path} must contain ports and exactly one of host or ip"
51
+ end
52
+
53
+ entry.key?('host') ? validate_host!(entry['host'], path) : validate_ip!(entry['ip'], path)
54
+ validate_ports!(entry['ports'], path)
55
+ entry.slice('host', 'ip')
56
+ end
57
+
58
+ def validate_host!(host, path)
59
+ if host.is_a?(String) && host.size <= 253 && host.match?(DNS_NAME) &&
60
+ host.include?('.') && !host.match?(/\A[\d.]+\z/)
61
+ return
62
+ end
63
+
64
+ raise ConfigurationError, "#{path}.host must be a lowercase DNS hostname"
65
+ end
66
+
67
+ # rubocop:disable-next Metrics/CyclomaticComplexity -- Exact IPv4 syntax and scoped benchmark exception share validation.
68
+ def validate_ip!(value, path, allow_benchmark: false)
69
+ raise IPAddr::InvalidAddressError unless value.is_a?(String) && !value.include?('/')
70
+
71
+ ip = IPAddr.new(value)
72
+ raise ConfigurationError, "#{path}.ip: IPv6 is not supported; the workspace is IPv4-only" if ip.ipv6?
73
+ if BENCHMARK.include?(ip) && !allow_benchmark
74
+ raise ConfigurationError, "#{path}.ip: benchmark addresses are reserved for validated redirect sources"
75
+ end
76
+ return if ip.to_s == value
77
+
78
+ raise ConfigurationError, "#{path}.ip must be an explicit IPv4 address"
79
+ rescue IPAddr::InvalidAddressError
80
+ raise ConfigurationError, "#{path}.ip must be an explicit IPv4 address", cause: nil
81
+ end
82
+
83
+ def validate_ports!(ports, path)
84
+ valid = ports.is_a?(Array) && !ports.empty? && ports.uniq.size == ports.size &&
85
+ ports.all? { |port| port.is_a?(Integer) && port.between?(1, 65_535) }
86
+ return if valid
87
+
88
+ raise ConfigurationError, "#{path}.ports must contain unique TCP ports from 1 through 65535"
89
+ end
90
+
91
+ class Resolver
92
+ def initialize(lookup: nil, logger: nil)
93
+ @lookup = lookup || method(:addresses)
94
+ @logger = logger
95
+ end
96
+
97
+ def resolve(config)
98
+ entries = config.is_a?(Array) ? config : []
99
+ return Resolved.new([].freeze) if entries.empty?
100
+
101
+ resolved = entries.map { |entry| resolve_entry(entry) }.sort_by { |entry| entry['host'] || entry['ip'] }
102
+ Resolved.new(resolved.freeze)
103
+ end
104
+
105
+ private
106
+
107
+ # rubocop:disable-next Metrics/AbcSize -- Resolve one complete nonsecret destination including the benchmark exclusion.
108
+ def resolve_entry(entry)
109
+ host = entry['host']
110
+ addresses = entry.key?('ip') ? [entry.fetch('ip')] : ipv4_addresses(host)
111
+ if addresses.any? { |address| BENCHMARK.include?(address) }
112
+ raise Error, 'Direct-egress resolution includes a benchmark address reserved for redirect sources'
113
+ end
114
+ raise Error, "Direct-egress host did not resolve to a supported IPv4 address: #{host}" if addresses.empty?
115
+
116
+ ports = entry.fetch('ports').sort
117
+ @logger&.call(operation: 'direct_egress_resolution', host: host, addresses: addresses, ports: ports)
118
+ entry.slice('host', 'ip').merge('addresses' => addresses, 'ports' => ports).freeze
119
+ rescue IPAddr::InvalidAddressError, SocketError
120
+ raise Error, "Direct-egress host could not be resolved: #{host}", cause: nil
121
+ end
122
+
123
+ def ipv4_addresses(host)
124
+ @lookup.call(host).map { |value| IPAddr.new(value) }.select(&:ipv4?).map(&:to_s).uniq.sort
125
+ end
126
+
127
+ def addresses(host)
128
+ Socket.getaddrinfo(host, nil, Socket::AF_UNSPEC, Socket::SOCK_STREAM).map { |entry| entry[3] }
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ class Egress < Definition
6
+ def identity
7
+ "#{workspace.id}:egress"
8
+ end
9
+
10
+ def backend_name
11
+ "empeira-#{workspace.id}-egress"
12
+ end
13
+
14
+ def ownership_labels
15
+ super.merge('io.empeira.purpose' => 'proxy-egress-network')
16
+ end
17
+
18
+ def verify_isolation!(resource)
19
+ raise UnsupportedPolicy, 'Proxy egress network unexpectedly isolated' unless resource && !resource.isolated
20
+ end
21
+
22
+ def to_h
23
+ super.merge('policy' => { 'isolated' => false, 'proxy_only' => true })
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ # One policy, addressing and TCP translation boundary for every peer backend.
6
+ class Gateway
7
+ OFFSETS = { 'gateway' => 2, 'dns' => 3, 'proxy' => 4, 'bootstrap-proxy' => 5 }.freeze
8
+ EXECUTABLE = '/usr/local/libexec/empeira-gateway'
9
+
10
+ def self.address(subnet, key = 'gateway')
11
+ Peer::Layout.new(subnet).address(OFFSETS.fetch(key))
12
+ end
13
+
14
+ # rubocop:disable-next Metrics/AbcSize -- Compose the complete provider-independent firewall input.
15
+ def self.plan(state:, resolved:, upstreams:, routes:, additional: nil, redirects: [])
16
+ subnet = state.fetch('peer_network').fetch('subnet')
17
+ resolvers = (upstreams + routes.values.flatten + additional_addresses(additional)).uniq.sort
18
+ unless resolvers.all? { |address| IPAddr.new(address).ipv4? }
19
+ raise Error, 'Gateway is IPv4-only; configure reachable IPv4 DNS upstreams'
20
+ end
21
+
22
+ { 'version' => 1, 'subnet' => subnet, 'gateway' => address(subnet),
23
+ 'dns' => address(subnet, 'dns'), 'resolvers' => resolvers,
24
+ 'proxies' => %w[proxy bootstrap-proxy].map { |key| address(subnet, key) },
25
+ 'blocked' => blocked_addresses(state), 'entries' => resolved.entries, 'redirects' => redirects }
26
+ end
27
+
28
+ def self.blocked_addresses(state)
29
+ state.fetch('nodes', {}).values.filter_map do |node|
30
+ node.dig('peer', 'ip') || node.dig('definition', 'ip') if node['network_phase'] == 'bootstrap'
31
+ end.sort
32
+ end
33
+
34
+ def self.additional_addresses(value)
35
+ return [] if value.nil? || value.end_with?('.empeira.internal')
36
+
37
+ return [value] if value.match?(/\A[\d.]+\z/)
38
+
39
+ Socket.getaddrinfo(value, nil, Socket::AF_INET, Socket::SOCK_STREAM).map { |entry| entry[3] }.uniq
40
+ rescue SocketError
41
+ raise Error, "Gateway DNS resolution failed for additional resolver #{value}", cause: nil
42
+ end
43
+
44
+ def initialize(context:, runtime:, state:)
45
+ @context = context
46
+ @runtime = runtime
47
+ @state = state
48
+ end
49
+
50
+ def route(resource)
51
+ subnet = @state.fetch('peer_network').fetch('subnet')
52
+ @runtime.configure_workspace_route(resource, gateway: self.class.address(subnet),
53
+ image: ControlPlane::Plan.new(context: @context)
54
+ .gateway_artifact.fetch(:image))
55
+ end
56
+
57
+ def update_phase_plan(ip, bootstrap:)
58
+ files = ControlPlane::Files.new(context: @context)
59
+ plan = JSON.parse(File.binread(files.path('gateway.json')))
60
+ plan['blocked'] = (plan.fetch('blocked') - [ip] + (bootstrap ? [ip] : [])).sort
61
+ files.write('gateway.json', JSON.generate(plan))
62
+ end
63
+
64
+ # Bootstrap exclusion is changed inside the gateway, before a new node starts.
65
+ def phase(record, bootstrap:)
66
+ identity = Services::Definition.new(key: 'gateway', workspace: @context.workspace)
67
+ resource = @runtime.inspect_service(identity,
68
+ expected_id: @state.dig('control_plane', 'services', 'gateway', 'id'))
69
+ raise Error, 'Gateway is unavailable; run empeira up' unless resource && resource['state'] == 'running'
70
+
71
+ ip = record.dig('peer', 'ip') || record.dig('definition', 'ip')
72
+ result = @runtime.service_exec(resource, [EXECUTABLE, bootstrap ? 'block' : 'unblock', ip])
73
+ raise Error, 'Gateway bootstrap firewall reconcile failed' unless result.success?
74
+
75
+ update_phase_plan(ip, bootstrap: bootstrap)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ class HostPolicy
6
+ def self.validate!(rules, path = 'proxy.rules')
7
+ raise ConfigurationError, "#{path} must be an array" unless rules.is_a?(Array)
8
+
9
+ rules.each_with_index { |rule, index| validate_rule!(rule, "#{path}.#{index}") }
10
+ end
11
+
12
+ def self.validate_rule!(rule, location)
13
+ raise ConfigurationError, "#{location} requires hosts and allow arrays" unless valid_rule_keys?(rule)
14
+ unless valid_hosts?(rule['hosts'])
15
+ raise ConfigurationError, "#{location}.hosts must contain lowercase hostname globs using * and ?"
16
+ end
17
+ return if rule['allow'].is_a?(Array) && rule['allow'].all? { |v| ProxyPolicy.valid_domain?(v) }
18
+
19
+ raise ConfigurationError, "#{location}.allow must contain DNS domains or *.domain entries"
20
+ end
21
+
22
+ def self.valid_rule_keys?(rule)
23
+ rule.is_a?(Hash) && rule.keys.sort_by(&:to_s) == %w[allow hosts]
24
+ end
25
+
26
+ def self.valid_hosts?(hosts)
27
+ hosts.is_a?(Array) && !hosts.empty? && hosts.all? { |v| valid_glob?(v) }
28
+ end
29
+
30
+ def self.valid_glob?(value)
31
+ HostnamePattern.valid?(value) && value == value.downcase
32
+ end
33
+
34
+ def initialize(config)
35
+ @config = config
36
+ end
37
+
38
+ def self.matches?(pattern, hostname)
39
+ HostnamePattern.matches?(pattern, hostname)
40
+ end
41
+
42
+ def matching(hostname)
43
+ @config.fetch('rules').select do |rule|
44
+ rule.fetch('hosts').any? { |pattern| HostnamePattern.matches?(pattern, hostname) }
45
+ end
46
+ end
47
+
48
+ def resolve(hostname)
49
+ (@config.fetch('global') + matching(hostname).flat_map { |rule| rule.fetch('allow') }).uniq
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ # Logical identities remain independent of runtime container names and addresses.
6
+ class Naming
7
+ DOMAIN = 'empeira.internal'
8
+
9
+ def hostname(service)
10
+ "#{service}.#{DOMAIN}"
11
+ end
12
+
13
+ def aliases(service)
14
+ [service, hostname(service)]
15
+ end
16
+
17
+ def reserved(config)
18
+ additional = config.dig('containers', 'additional').map { |entry| entry.fetch('name') }
19
+ Configuration::AdditionalServices::RESERVED + additional
20
+ end
21
+
22
+ def no_proxy(additional = [])
23
+ ['localhost', '127.0.0.1', '::1', ".#{DOMAIN}", 'server', 'puppetdb', 'postgres', 'dns', 'proxy',
24
+ *additional].uniq.join(',')
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ module Peer
6
+ class AdapterImage
7
+ attr_reader :image, :recipe, :files
8
+
9
+ # rubocop:disable-next Metrics/AbcSize -- Assemble the reviewed source and resolved builder identity together.
10
+ def initialize(context:)
11
+ root = Pathname(__dir__).join('../../../../resources/network/adapter').realpath
12
+ @files = source_files(root)
13
+ builder = Images::Configuration.reference(context.configuration.dig('images', 'network_adapter_builder'),
14
+ registry: context.configuration.dig('images', 'registry'))
15
+ digest = Infrastructure::Definition.fingerprint(@files)
16
+ @recipe = root.join('Containerfile').read.gsub('@BUILDER@', builder) + "\n# source-sha256: #{digest}\n"
17
+ @image = Images::Configuration.local_image(@recipe, purpose: 'peer-adapter')
18
+ end
19
+
20
+ def source_files(root)
21
+ files = root.children.select { |path| path.extname == '.go' || path.basename.to_s == 'go.mod' }
22
+ .to_h { |path| [path.basename.to_s, path.binread] }
23
+ files.merge('LICENSE' => Pathname(__dir__).join('../../../../LICENSE').read)
24
+ end
25
+
26
+ def ensure!(runtime)
27
+ runtime.ensure_image(image, recipe: recipe, files: files)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ module Peer
6
+ class Allocation
7
+ def initialize(context:, runtime:, runner:)
8
+ @context = context
9
+ @runtime = runtime
10
+ @runner = runner
11
+ end
12
+
13
+ def choose
14
+ occupied = @runtime.network_subnets + host_routes
15
+ candidates.find { |candidate| occupied.none? { |route| overlap?(candidate, route) } } ||
16
+ raise(Error, 'No safe private peer subnet is available; inspect host/VPN and runtime routes')
17
+ end
18
+
19
+ private
20
+
21
+ def candidates
22
+ offset = Digest::SHA256.hexdigest(@context.workspace.id)[0, 4].to_i(16)
23
+ candidates = (0...256).map { |i| "10.#{160 + ((offset + i) % 64)}.#{(offset + i) % 256}.0/24" }
24
+ candidates + alternatives(offset)
25
+ end
26
+
27
+ def alternatives(offset)
28
+ (16..31).map { |i| "172.#{i}.#{offset % 256}.0/24" } +
29
+ (0...256).map { |i| "192.168.#{(offset + i) % 256}.0/24" }
30
+ end
31
+
32
+ def overlap?(candidate, route)
33
+ left, right = [candidate, route].map { |value| IPAddr.new(value) }
34
+ left.include?(right.to_range.first) || right.include?(left.to_range.first)
35
+ rescue IPAddr::InvalidAddressError
36
+ raise Error, 'Cannot verify route overlap for the peer network', cause: nil
37
+ end
38
+
39
+ def host_routes
40
+ Platform::Routes.new(platform: @context.platform, runner: @runner).ipv4
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ module Peer
6
+ class Attachment
7
+ def initialize(backend:, record:, state:)
8
+ @backend = backend
9
+ @record = record
10
+ @state = state
11
+ end
12
+
13
+ def arguments
14
+ @backend.arguments(@record)
15
+ end
16
+
17
+ def launch(executable, arguments)
18
+ @backend.launch(executable, arguments)
19
+ end
20
+
21
+ def connect
22
+ @backend.connect(@record, @state)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ module Peer
6
+ class Backend
7
+ attr_reader :context, :runtime, :runner, :store
8
+
9
+ def self.build(context:, **)
10
+ klass = if context.container_engine == 'docker'
11
+ DockerAdapter
12
+ elsif context.platform.os == :macos
13
+ PodmanMachine
14
+ else
15
+ LinuxPodman
16
+ end
17
+ klass.new(context: context, **)
18
+ end
19
+
20
+ def initialize(context:, runtime:, runner:, store:)
21
+ @context = context
22
+ @runtime = runtime
23
+ @runner = runner
24
+ @store = store
25
+ end
26
+
27
+ def key
28
+ self.class.name.split('::').last
29
+ end
30
+
31
+ def preflight(state)
32
+ raise Error, 'Old VM network state requires explicit teardown and recreation' unless state['peer_network']
33
+
34
+ resource = owned_network(state)
35
+ details = runtime.network_details(resource.id)
36
+ verify_subnet(details, state.fetch('peer_network').fetch('subnet'))
37
+ end
38
+
39
+ def owned_network(state)
40
+ definition = Network::Definition.new(workspace: context.workspace, policy: Network::Policy.new)
41
+ resource = runtime.inspect_network(identifier: definition.backend_name)
42
+ definition.verify_ownership!(resource, expected_id: state.dig('resources', 'network', 'id'))
43
+ definition.verify_isolation!(resource)
44
+ raise Error, 'Owned peer network is missing; run empeira up' unless resource
45
+
46
+ resource
47
+ end
48
+
49
+ def verify_subnet(details, expected)
50
+ subnets = details['subnets'] || details.dig('IPAM', 'Config') || []
51
+ return if subnets.any? { |entry| (entry['subnet'] || entry['Subnet']) == expected }
52
+
53
+ raise Providers::OwnershipError, 'Observed peer subnet differs from workspace inventory'
54
+ end
55
+
56
+ def network_name
57
+ Network::Definition.new(workspace: context.workspace, policy: Network::Policy.new).backend_name
58
+ end
59
+
60
+ def bridge
61
+ "ep#{context.workspace.id[0, 10]}"
62
+ end
63
+
64
+ def tap(record)
65
+ "et#{record.fetch('peer').fetch('token')[0, 12]}"
66
+ end
67
+
68
+ def command(executable, *arguments, timeout: 30)
69
+ result = runner.run(executable, arguments: arguments, timeout: timeout)
70
+ return result.stdout if result.success?
71
+
72
+ raise Error,
73
+ "Peer network #{executable} operation failed (exit=#{result.exit_status}, timeout=#{result.timed_out})"
74
+ end
75
+
76
+ def check_record(record)
77
+ return if record.dig('peer', 'backend') == key
78
+
79
+ raise Error, 'Peer backend differs from the node instance; restore its runtime and platform'
80
+ end
81
+
82
+ def health(records)
83
+ records.to_h { |record| [record.fetch('hostname'), healthy?(record)] }
84
+ end
85
+
86
+ def system_address(record, port)
87
+ Configuration::SSHPreferences.port!(port)
88
+ check_record(record)
89
+ address = IPAddr.new(record.fetch('peer').fetch('ip'))
90
+ subnet = system_subnet
91
+ unless address.ipv4? && address.private? && subnet.include?(address)
92
+ raise Providers::OwnershipError, 'System SSH target is outside the owned peer subnet'
93
+ end
94
+
95
+ address.to_s
96
+ end
97
+
98
+ def system_subnet
99
+ state = store.load
100
+ resource = owned_network(state)
101
+ subnet = state.fetch('peer_network').fetch('subnet')
102
+ verify_subnet(runtime.network_details(resource.id), subnet)
103
+ IPAddr.new(subnet)
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empeira
4
+ module Network
5
+ module Peer
6
+ # Control is private and token-bound. The packet stream never carries application configuration.
7
+ class Channel
8
+ def initialize(context:, runner:, record:)
9
+ @runner = runner
10
+ @token = record.fetch('peer').fetch('token')
11
+ paths = [context.locations.temporary, context.locations.home].map { |root| root.join("ep-#{@token[0, 24]}") }
12
+ @directory = paths.find { |path| path.join('ethernet.sock').to_s.bytesize < 100 }
13
+ raise Error, 'No short private path is available for the peer packet channel' unless @directory
14
+ end
15
+
16
+ def socket
17
+ @directory.join('ethernet.sock').to_s
18
+ end
19
+
20
+ def prepare
21
+ FileUtils.mkdir_p(@directory, mode: 0o700)
22
+ verify_directory
23
+ raise Error, 'Peer channel is still active; stop its owning VM before retrying' if live?
24
+
25
+ %w[control.sock ethernet.sock].each { |name| remove_socket(@directory.join(name)) }
26
+ end
27
+
28
+ def start(command)
29
+ launch(command)
30
+ Timeout.timeout(10) do
31
+ loop do
32
+ return if healthy?
33
+
34
+ sleep 0.1
35
+ end
36
+ end
37
+ rescue Timeout::Error
38
+ raise Error, 'Peer packet channel did not become ready; inspect adapter/runtime availability'
39
+ end
40
+
41
+ def healthy?
42
+ reply = request('status')
43
+ reply['token'] == @token && reply['running'] == true
44
+ rescue SystemCallError, IOError, Timeout::Error, JSON::ParserError
45
+ false
46
+ end
47
+
48
+ def stop
49
+ return unless @directory.exist?
50
+
51
+ verify_directory
52
+ if live?
53
+ reply = request('stop')
54
+ raise Providers::OwnershipError, 'Peer channel ownership changed' unless reply['token'] == @token
55
+
56
+ Timeout.timeout(15) { sleep 0.1 while live? }
57
+ end
58
+ %w[control.sock ethernet.sock].each { |name| remove_socket(@directory.join(name)) }
59
+ rescue SystemCallError, Timeout::Error
60
+ raise Error, 'Peer channel shutdown is uncertain; resources retained for recovery', cause: nil
61
+ end
62
+
63
+ def destroy
64
+ stop
65
+ FileUtils.remove_entry_secure(@directory) if @directory.exist?
66
+ end
67
+
68
+ private
69
+
70
+ def launch(command)
71
+ config = @directory.join('channel.json')
72
+ File.write(config, JSON.generate('token' => @token, 'command' => command), mode: 'w', perm: 0o600)
73
+ helper = Pathname(__dir__).join('../../../../resources/network/channel.rb').realpath
74
+ result = @runner.run(RbConfig.ruby, arguments: [helper.to_s, config.to_s], timeout: 10)
75
+ raise Error, 'Cannot launch peer packet channel' unless result.success?
76
+ end
77
+
78
+ def request(operation)
79
+ Timeout.timeout(2) do
80
+ UNIXSocket.open(@directory.join('control.sock')) do |connection|
81
+ connection.puts(JSON.generate('token' => @token, 'operation' => operation))
82
+ JSON.parse(connection.gets(4096))
83
+ end
84
+ end
85
+ end
86
+
87
+ def live?
88
+ path = @directory.join('channel.pid')
89
+ return false unless path.file?
90
+
91
+ Process.kill(0, Integer(path.read))
92
+ true
93
+ rescue Errno::ESRCH
94
+ false
95
+ end
96
+
97
+ def verify_directory
98
+ metadata = @directory.lstat
99
+ unless metadata.directory? && metadata.uid == Process.uid && metadata.mode.nobits?(0o077)
100
+ raise Providers::OwnershipError, 'Peer channel directory is not private to its owner'
101
+ end
102
+
103
+ verify_saved_token
104
+ end
105
+
106
+ def verify_saved_token
107
+ path = @directory.join('channel.json')
108
+ return unless path.exist? || path.symlink?
109
+
110
+ metadata = path.lstat
111
+ unless private_file?(metadata) && JSON.parse(path.read)['token'] == @token
112
+ raise Providers::OwnershipError, 'Peer channel ownership token changed; cleanup refused'
113
+ end
114
+ rescue JSON::ParserError
115
+ raise Providers::OwnershipError, 'Invalid peer channel ownership file', cause: nil
116
+ end
117
+
118
+ def private_file?(metadata)
119
+ metadata.file? && metadata.uid == Process.uid && metadata.mode.nobits?(0o077)
120
+ end
121
+
122
+ def remove_socket(path)
123
+ return unless path.exist? || path.symlink?
124
+ unless path.lstat.socket? && path.lstat.uid == Process.uid
125
+ raise Providers::OwnershipError, 'Unexpected file at the peer socket path'
126
+ end
127
+
128
+ File.unlink(path)
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end