empeira 0.1.0.pre.alpha.4 → 0.1.0.pre.alpha.6

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e4e7094658b9e3d8f4ee0edbafa3e4e45be28921900cce8eb4af7d66096cee6
4
- data.tar.gz: 7f062dc35a9cef14ad52f55362beba6c4a5a0ab1b57225ce27a191123dc01fc9
3
+ metadata.gz: 6a6bd2bd179b9a3fa789f2342e0646d9e5d681941cd2df234770cf8a94a01f31
4
+ data.tar.gz: 8925fd4ded29c7206cfe6c1d2d072f7a41a649582504dbf1a29232543525aacf
5
5
  SHA512:
6
- metadata.gz: 8410139aeffa1ccaab90f3ddac57a809a86420e385f1354f801f8181c936ecf5653762cc4dbade0178d6df99b2dad1cc8b68cb8d537013132ec09e2ed65aefa1
7
- data.tar.gz: 52ba2b37546bc90263d9834193b5080440e2a66425c9f9403b19800e791bf5f26b895d4c4e1af4bc218951b3a0e433437c07c550f493f4577e4e8dfd156a06bb
6
+ metadata.gz: 988ab09ce7e604a6bddfe1f5acad7f2847939b96e33ad6b73d06a03d49a6aafc7048222f5bb2a26eddb412dd75442b6d57addddd9558cb974a675603c11a1fb6
7
+ data.tar.gz: 6d5c737b4f18f0d400f279c59b0b10f7e68bb3b564c649b42207e0f447ba7b68a2acd93b34258ca7d025252d91de4c2befcfc741b85195f23a8b8feecaa99e46
data/config/defaults.yaml CHANGED
@@ -50,12 +50,18 @@ puppetdb:
50
50
  bootstrap_config: /etc/puppetlabs/puppetdb/bootstrap.cfg
51
51
  data: /opt/puppetlabs/server/data/puppetdb
52
52
  network:
53
- egress: []
53
+ egress:
54
+ nodes: []
55
+ infrastructure: []
54
56
  redirects: []
55
57
  proxy:
56
- enabled: false
57
- global: []
58
- rules: []
58
+ nodes:
59
+ enabled: false
60
+ global: []
61
+ rules: []
62
+ infrastructure:
63
+ enabled: false
64
+ allow: []
59
65
  vm:
60
66
  disk: 30
61
67
  interfaces: []
data/docs/addons.md CHANGED
@@ -208,8 +208,12 @@ Array elements can be mappings, arrays or scalars. For example:
208
208
 
209
209
  ```yaml
210
210
  proxy:
211
- enabled: true
212
- global: [forge.puppet.com, github.com]
211
+ nodes:
212
+ enabled: true
213
+ global: [forge.puppet.com, github.com]
214
+ infrastructure:
215
+ enabled: false
216
+ allow: []
213
217
  server:
214
218
  mounts:
215
219
  - source: /home/user/data
@@ -217,10 +221,10 @@ server:
217
221
  readonly: true
218
222
  ```
219
223
 
220
- produces `EMPEIRA_CONFIG_PROXY_ENABLED=true`,
221
- `EMPEIRA_CONFIG_PROXY_GLOBAL_COUNT=2`,
222
- `EMPEIRA_CONFIG_PROXY_GLOBAL_0=forge.puppet.com`,
223
- `EMPEIRA_CONFIG_PROXY_GLOBAL_1=github.com`,
224
+ produces `EMPEIRA_CONFIG_PROXY_NODES_ENABLED=true`,
225
+ `EMPEIRA_CONFIG_PROXY_NODES_GLOBAL_COUNT=2`,
226
+ `EMPEIRA_CONFIG_PROXY_NODES_GLOBAL_0=forge.puppet.com`,
227
+ `EMPEIRA_CONFIG_PROXY_NODES_GLOBAL_1=github.com`,
224
228
  `EMPEIRA_CONFIG_SERVER_MOUNTS_COUNT=1`, and the variables
225
229
  `EMPEIRA_CONFIG_SERVER_MOUNTS_0_SOURCE`, `_TARGET`, `_READONLY`.
226
230
 
@@ -107,9 +107,15 @@ runtime:
107
107
  server:
108
108
  environment: production
109
109
  network:
110
- egress: []
110
+ egress:
111
+ nodes: []
112
+ infrastructure: []
111
113
  proxy:
112
- enabled: false
114
+ nodes:
115
+ enabled: false
116
+ infrastructure:
117
+ enabled: false
118
+ allow: []
113
119
  node_defaults:
114
120
  os: ubuntu
115
121
  version: "24.04"
@@ -150,11 +156,14 @@ release version. A partial project mapping may omit it and inherit the default.
150
156
  | `server.timeout` | Positive readiness timeout in seconds per component, `240` |
151
157
  | `server.mounts` | Additional server-only host bind mounts, `[]`; `source`, `target`, optional boolean `readonly` (default `true`) |
152
158
  | `puppetdb.memory` | Positive integer MiB, `768` |
153
- | `network.egress` | Direct TCP proxy-bypass host/port entries, `[]`; a project array replaces the default array |
159
+ | `network.egress.nodes` | Node-only direct TCP host/port grants, `[]`; arrays replace |
160
+ | `network.egress.infrastructure` | Infrastructure-only direct TCP host/port grants, `[]`; arrays replace |
154
161
  | `network.redirects` | Exact IPv4/protocol/port endpoints redirected to enabled internal services, `[]` |
155
- | `proxy.enabled` | Boolean, `false`; creates the normal HTTP/HTTPS policy proxy |
156
- | `proxy.global` | DNS destination array, `[]` |
157
- | `proxy.rules` | Additive hostname-glob rules with `hosts` and `allow` arrays, `[]` |
162
+ | `proxy.nodes.enabled` | Boolean, `false`; enables normal node HTTP/HTTPS proxy access |
163
+ | `proxy.nodes.global` | DNS destination array, `[]` |
164
+ | `proxy.nodes.rules` | Additive node hostname-glob rules with `hosts` and `allow` arrays, `[]` |
165
+ | `proxy.infrastructure.enabled` | Boolean, `false`; enables infrastructure HTTP/HTTPS proxy access |
166
+ | `proxy.infrastructure.allow` | Independent infrastructure DNS destination array, `[]` |
158
167
  | `vm.disk` | New VM virtual disk capacity in GiB, integer `1`–`2048`, default `30`; must accommodate the base image |
159
168
  | `vm.interfaces` | VM-only hostname rules defining dummy/VLAN devices with explicit IPv4 CIDR host addresses, `[]` |
160
169
  | `vm.console.root_password` | Initial console password, `empeira`; a string overrides it and `null` disables it |
@@ -488,14 +497,20 @@ The numeric gem packaging fallback is never used to bypass this check.
488
497
  ```yaml
489
498
  network:
490
499
  egress:
491
- - host: api.example.com
492
- ports: [443]
500
+ nodes:
501
+ - host: api.example.com
502
+ ports: [443]
503
+ infrastructure: []
493
504
  proxy:
494
- enabled: true
495
- global: [forgeapi.puppet.com]
496
- rules:
497
- - hosts: ["*-web-*"]
498
- allow: [github.com, "*.github.com"]
505
+ nodes:
506
+ enabled: true
507
+ global: [forgeapi.puppet.com]
508
+ rules:
509
+ - hosts: ["*-web-*"]
510
+ allow: [github.com, "*.github.com"]
511
+ infrastructure:
512
+ enabled: false
513
+ allow: []
499
514
  hiera:
500
515
  mounts:
501
516
  - source: ../hieradata
@@ -507,7 +522,12 @@ vm:
507
522
  root_password: null
508
523
  ```
509
524
 
510
- When `proxy.enabled` is true, every node may use its matching destination policy.
525
+ When `proxy.nodes.enabled` is true, every node may use its matching destination policy.
526
+ Infrastructure access is separately controlled by `proxy.infrastructure.enabled`
527
+ and `proxy.infrastructure.allow`; both scopes default to disabled. Disabled
528
+ infrastructure has no configured proxy endpoint even when nodes use the proxy.
529
+ Keep its direct-egress list empty for internal-only infrastructure. See the
530
+ [migration instructions](proxy.md#migrating-shared-settings) for former shared keys.
511
531
  After bootstrap cleanup, Empeira reconciles normal proxy settings on running,
512
532
  provisioned nodes during `up` and `node start`. Repeated Puppet runs use the
513
533
  existing guest configuration. No
@@ -719,21 +739,24 @@ desired state; Puppet owns package state after the first catalog.
719
739
 
720
740
  ## Direct egress
721
741
 
722
- `network.egress` contains destination TCP grants, independent of HTTP/HTTPS proxy
723
- policy. Entries have exactly one `host` or IPv4 `ip` and a nonempty unique `ports`
742
+ `network.egress.nodes` and `network.egress.infrastructure` contain separate
743
+ destination TCP grants, independent of HTTP/HTTPS proxy policy. A node grant never
744
+ applies to infrastructure or conversely. Entries have exactly one `host` or IPv4 `ip` and a nonempty unique `ports`
724
745
  array (1–65535). `up` resolves all A records and replaces the complete policy;
725
746
  resolution/apply failure blocks egress. IPv6 and external UDP grants are unsupported.
726
747
 
727
748
  ```yaml
728
749
  network:
729
750
  egress:
730
- - host: repository.example.net
731
- ports: [443]
732
- - ip: 203.0.113.10
733
- ports: [443, 8443]
751
+ nodes:
752
+ - host: repository.example.net
753
+ ports: [443]
754
+ - ip: 203.0.113.10
755
+ ports: [443, 8443]
756
+ infrastructure: []
734
757
  ```
735
758
 
736
- Named direct destinations enter generated `NO_PROXY`; applications choose DIRECT
759
+ Direct destinations enter only their own scope's generated `NO_PROXY`; applications choose DIRECT
737
760
  or proxy themselves. See [networking](networking.md#one-gateway-one-direct-egress-policy)
738
761
  for routing, revocation and privilege boundaries.
739
762
 
@@ -765,7 +788,7 @@ vm:
765
788
  network: 192.0.2.20/32
766
789
  ```
767
790
 
768
- Patterns use the same `*`/`?` glob matcher as `proxy.rules`, against the full stored
791
+ Patterns use the same `*`/`?` glob matcher as `proxy.nodes.rules`, against the full stored
769
792
  node hostname, case-insensitively. Patterns within a rule are alternatives. All
770
793
  matching rules contribute devices; identical definitions merge once. A conflicting
771
794
  definition names the hostname, device and both configuration paths. No match creates
@@ -207,8 +207,8 @@ The obsolete `images.modules` key is rejected.
207
207
  The disposable installer belongs to the update plane. It uses the selected
208
208
  runtime's normal bridge network (`bridge` on Docker, `podman` on Podman), with
209
209
  normal runtime DNS and external connectivity. It never joins an Empeira workspace
210
- network and needs neither CoreDNS nor Squid. Workspace `network.egress`, `proxy.enabled`,
211
- `proxy.global` and `proxy.rules` do not affect
210
+ network and needs neither CoreDNS nor Squid. Workspace `network.egress`, `proxy.nodes.enabled`,
211
+ `proxy.nodes.global` and `proxy.nodes.rules` do not affect
212
212
  Forge or Git acquisition. Supplied `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and their
213
213
  lowercase equivalents are inherited unchanged; Empeira generates no proxy URL.
214
214
  The host/runtime's normal network and corporate-proxy configuration must permit
data/docs/networking.md CHANGED
@@ -102,17 +102,26 @@ Every peer uses the owned gateway as its IPv4 default route. Only the gateway ha
102
102
  an external uplink; DNS, proxies, server, database and nodes stay isolated.
103
103
  The fixed browser UI relay has a separate loopback access path with forwarding off.
104
104
 
105
- `network.egress` grants destination IPv4/TCP-port pairs:
105
+ `network.egress.nodes` and `network.egress.infrastructure` grant destination
106
+ IPv4/TCP-port pairs independently:
106
107
 
107
108
  ```yaml
108
109
  network:
109
110
  egress:
110
- - host: repository.example.net
111
- ports: [443]
112
- - ip: 203.0.113.10
113
- ports: [443, 8443]
111
+ nodes:
112
+ - host: repository.example.net
113
+ ports: [443]
114
+ - ip: 203.0.113.10
115
+ ports: [443, 8443]
116
+ infrastructure: []
114
117
  ```
115
118
 
119
+ The gateway restricts node grants to the reserved VM/container node address ranges
120
+ and infrastructure grants to its separate runtime allocation pool. Both directions
121
+ of direct connections are source-bound. The shared workspace subnet is never used
122
+ as a blanket source for direct grants. Custom gateway images must advertise
123
+ `scoped-egress-v1`; an older image fails closed before receiving external access.
124
+
116
125
  Entries have exactly one `host` or `ip`. Hostnames match exactly, without wildcards;
117
126
  `up` resolves every current A record and replaces the complete policy. IP selectors
118
127
  bypass DNS and may intentionally permit private LAN/VPN destinations. A different
@@ -128,9 +137,9 @@ CoreDNS alone receives TCP/UDP 53 to its selected resolvers. Proxies alone recei
128
137
  TCP 80/443 and enforce their destination allowlists. Normal proxy allow rules apply
129
138
  regardless of target IP range; the temporary authenticated bootstrap proxy retains
130
139
  its separate private/reserved-address restrictions.
131
- `proxy.global` does not grant direct egress. Applications choose DIRECT/proxy using
140
+ `proxy.nodes.global` does not grant direct egress. Applications choose DIRECT/proxy using
132
141
  their own settings and `NO_PROXY`; direct/proxy selection has no automatic fallback.
133
- Both explicit hostnames and IPv4 selectors from `network.egress` are included in
142
+ Both explicit hostnames and IPv4 selectors are included only in their own scope's
134
143
  normal proxy bypass settings. The gateway still permits only their configured TCP ports.
135
144
  See [proxy policy](proxy.md) for hostname rules and bootstrap destinations.
136
145
 
data/docs/proxy.md CHANGED
@@ -1,19 +1,23 @@
1
1
  # Proxy policy
2
2
 
3
- Normal external HTTP/HTTPS access stays behind Squid. Enabling the workspace proxy
4
- makes it available to every node; global and hostname rules remain the destination
5
- allowlist:
3
+ Normal external HTTP/HTTPS access stays behind Squid. Nodes and infrastructure
4
+ have independent switches and destination lists, both disabled by default. Enabling
5
+ the node scope makes its global and hostname rules available to nodes:
6
6
 
7
7
  ```yaml
8
8
  proxy:
9
- enabled: true
10
- global:
11
- - forgeapi.puppet.com
12
- rules:
13
- - hosts: ["lab-*"]
14
- allow: ["*.github.com"]
15
- - hosts: ["*-web-*"]
16
- allow: [github.com, nginx.org]
9
+ nodes:
10
+ enabled: true
11
+ global:
12
+ - forgeapi.puppet.com
13
+ rules:
14
+ - hosts: ["lab-*"]
15
+ allow: ["*.github.com"]
16
+ - hosts: ["*-web-*"]
17
+ allow: [github.com, nginx.org]
18
+ infrastructure:
19
+ enabled: false
20
+ allow: []
17
21
  ```
18
22
 
19
23
  ```console
@@ -46,7 +50,7 @@ infrastructure has already been reconciled.
46
50
 
47
51
  ## Matching and destinations
48
52
 
49
- Defaults are `proxy.global: []` and `proxy.rules: []`. Every node receives
53
+ Defaults are `proxy.nodes.global: []` and `proxy.nodes.rules: []`. Every node receives
50
54
  all global destinations plus `allow` from **every** matching rule. Duplicate entries
51
55
  are removed while preserving configuration order. A rule matches when any of its
52
56
  `hosts` patterns matches the complete lowercase hostname. Patterns must be lowercase.
@@ -66,14 +70,14 @@ Destination rules are separate from hostname globs:
66
70
  Only HTTP port 80 and HTTPS CONNECT port 443 are allowed. Allowed DNS destinations
67
71
  are reachable regardless of their target IP range, including private, loopback,
68
72
  link-local and reserved addresses. Domains without a matching allow rule remain
69
- denied. There is no TLS interception. Server-side outbound traffic gets
70
- `proxy.global` only; node hostname rules do not expand server access.
73
+ denied. There is no TLS interception. Node destinations never grant infrastructure
74
+ access, and infrastructure destinations never grant node access.
71
75
 
72
76
  ## Node policy and reconciliation
73
77
 
74
- When `proxy.enabled` is true, all nodes are authorized as proxy clients. Nodes get
78
+ When `proxy.nodes.enabled` is true, all nodes are authorized as proxy clients. Nodes get
75
79
  upper/lowercase proxy variables and internal `NO_PROXY` entries, including explicitly
76
- configured `network.egress` hosts. Those named hosts use the common workspace
80
+ configured `network.egress.nodes` hosts. Those named hosts use the common workspace
77
81
  gateway for their configured ports. VM access uses the same destination policy bound
78
82
  directly to the VM instance's reserved peer address. The proxy is reached at
79
83
  `proxy.empeira.internal:3128` on the isolated network. A request without a matching
@@ -90,17 +94,56 @@ Container and VM nodes consume the new policy without a restart; open proxy
90
94
  connections can end during reconciliation. Enabling/disabling proxy mode is a
91
95
  separate topology change and may require explicit node cleanup as reported by `up`.
92
96
 
93
- Changing direct-egress destinations also changes the server's `NO_PROXY`
94
- environment. `up` replaces a server whose environment definition changes while
97
+ Changing `network.egress.infrastructure` destinations also changes infrastructure
98
+ `NO_PROXY`. Changing node destinations does not change the server environment. `up` replaces a server whose environment definition changes while
95
99
  retaining CA storage and node identities. Changing only destination ports or
96
100
  transparent redirect rules preserves the server when its definition is unchanged.
97
101
 
102
+ ## Infrastructure policy
103
+
104
+ `proxy.infrastructure.enabled` controls the Puppet/OpenVox server, PostgreSQL,
105
+ PuppetDB backend/relay and `containers.additional` services. Its independent
106
+ `allow` list uses the same exact-domain and domain-family syntax as node grants.
107
+ The browser, DNS, Squid and gateway transports do not receive these application
108
+ proxy settings.
109
+
110
+ ```yaml
111
+ proxy:
112
+ infrastructure:
113
+ enabled: true
114
+ allow: [api.example.net]
115
+ ```
116
+
117
+ When disabled, infrastructure definitions contain empty proxy values, overriding
118
+ any image defaults. They contain no configured proxy endpoint and receive no Squid
119
+ client grants, even if the node proxy is enabled. No startup wrapper removes an
120
+ otherwise configured proxy. Internal names and mocks remain directly reachable.
121
+ When enabled, only infrastructure's own direct destinations enter its `NO_PROXY`.
122
+ Explicit environment settings in additional-service definitions remain application
123
+ configuration; they do not add Squid permissions.
124
+
125
+ `up` creates the shared normal proxy when either scope is enabled and removes it
126
+ when both are disabled. Changing infrastructure's switch updates affected service
127
+ definitions while retaining storage and node identities. Allowlist-only edits reload
128
+ Squid without replacing unrelated services. Direct grants remain independent of
129
+ both proxy switches; keep `network.egress.infrastructure: []` for internal-only
130
+ infrastructure.
131
+
132
+ ## Migrating shared settings
133
+
134
+ The former `proxy.enabled`, `proxy.global` and `proxy.rules` keys are rejected with
135
+ a migration diagnostic. Move them under `proxy.nodes`; configure infrastructure
136
+ explicitly with its own switch and `allow` list. Old shared `network.egress` arrays
137
+ and legacy `mode` values are also rejected. Move grants to `network.egress.nodes`
138
+ or `network.egress.infrastructure` according to the intended clients. No existing
139
+ grant is silently copied to infrastructure.
140
+
98
141
  ## Managed bootstrap
99
142
 
100
143
  Managed bootstrap destinations are separate from normal user policy. Reviewed
101
144
  agent and distribution endpoints in `agent.install` and `bootstrap.guests` allow
102
145
  Puppet/OpenVox and required guest packages to be installed without duplicating them
103
- in `proxy.global`. The temporary authenticated bootstrap proxy uses workspace DNS
146
+ in `proxy.nodes.global`. The temporary authenticated bootstrap proxy uses workspace DNS
104
147
  and retains its private, loopback, link-local and reserved destination-address
105
148
  restrictions independently of normal proxy policy. Its credential is not stored in
106
149
  the guest seed, JSON inventory or logs. A /32 source ACL admits only the provisioning
@@ -132,7 +132,7 @@ or restart of that same VM conflicts with the shared instance guard: detach with
132
132
  ## Proxy destination denied
133
133
 
134
134
  Run `empeira proxy show HOSTNAME` and check every matching rule. Verify the node
135
- exists in the workspace, `proxy.enabled` is true, and `up` has reconciled the
135
+ exists in the workspace, `proxy.nodes.enabled` is true, and `up` has reconciled the
136
136
  configuration and current node addresses. Allowed DNS destinations may resolve to
137
137
  private or reserved IP addresses; only HTTP port 80 and HTTPS CONNECT port 443 are
138
138
  permitted. After updating Empeira, run `empeira up` to replace a proxy still using
@@ -367,6 +367,6 @@ external runtime network as a workaround. Docker workspace bridge attachment
367
367
  errors require an accessible Linux-engine host namespace and an identifiable
368
368
  iptables backend. The helper changes only owned bridge rules. Do not disable the
369
369
  host firewall globally. Check Docker Desktop host-network restrictions separately.
370
- Proxy errors require checking Squid source bindings and `proxy.global`/`proxy.rules`,
370
+ Proxy errors require checking Squid source bindings and `proxy.nodes.global`/`proxy.nodes.rules`,
371
371
  not adding unrelated direct grants. Corporate DNS still follows host discovery;
372
372
  IPv6-only upstreams must be replaced with reachable IPv4 resolvers for this backend.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-alpha.4",
3
- "revision": "b073c6a15db14c4dace4a732703240f4289cbb48",
4
- "build_time": "2026-10-21T11:41:02Z"
2
+ "version": "0.1.0-alpha.6",
3
+ "revision": "398c47f3abc571dd3c6668f62399eb624b465019",
4
+ "build_time": "2026-10-23T07:29:14Z"
5
5
  }
@@ -5,7 +5,7 @@ module Empeira
5
5
  class Proxy < Base
6
6
  desc 'show HOSTNAME', 'Explain the normal proxy destination policy for a hostname'
7
7
  def show(hostname)
8
- config = application.context.configuration.fetch('proxy')
8
+ config = application.context.configuration.dig('proxy', 'nodes')
9
9
  resolver = Network::HostPolicy.new(config)
10
10
  say "Effective proxy policy for #{hostname.downcase}"
11
11
  say access_message(config)
@@ -19,7 +19,7 @@ module Empeira
19
19
  return "All workspace nodes may use this policy after 'empeira up' reconciles the proxy." if
20
20
  config.fetch('enabled')
21
21
 
22
- 'The normal workspace proxy is disabled.'
22
+ 'The normal node proxy is disabled.'
23
23
  end
24
24
 
25
25
  def display_matches(rules)
@@ -20,8 +20,10 @@ module Empeira
20
20
  'environment' => :environment,
21
21
  'memory' => :positive_integer, 'cpus' => :positive_integer, 'timeout' => :positive_integer },
22
22
  'puppetdb' => { 'enabled' => :boolean, 'memory' => :positive_integer, 'runtime' => :puppetdb_runtime },
23
- 'network' => { 'egress' => :network_egress, 'redirects' => :network_redirects },
24
- 'proxy' => { 'enabled' => :boolean, 'global' => :allowlist, 'rules' => :proxy_rules },
23
+ 'network' => { 'egress' => { 'nodes' => :network_egress, 'infrastructure' => :network_egress },
24
+ 'redirects' => :network_redirects },
25
+ 'proxy' => { 'nodes' => { 'enabled' => :boolean, 'global' => :allowlist, 'rules' => :proxy_rules },
26
+ 'infrastructure' => { 'enabled' => :boolean, 'allow' => :allowlist } },
25
27
  'vm' => { 'disk' => :vm_disk, 'interfaces' => :vm_interfaces,
26
28
  'console' => { 'root_password' => :optional_string } },
27
29
  'dns' => { 'upstream' => { 'mode' => :dns_mode, 'servers' => :dns_servers },
@@ -116,12 +118,35 @@ module Empeira
116
118
  end
117
119
 
118
120
  def validate_fragment!(data)
121
+ validate_scope_migration!(data)
119
122
  validate_mapping!(data, RULES, [])
120
123
  data
121
124
  end
122
125
 
123
126
  private
124
127
 
128
+ def validate_scope_migration!(data)
129
+ return unless data.is_a?(Hash)
130
+
131
+ validate_proxy_scope_migration!(data['proxy'])
132
+ network = data['network']
133
+ validate_egress_scope_migration!(network['egress']) if network.is_a?(Hash)
134
+ end
135
+
136
+ def validate_proxy_scope_migration!(proxy)
137
+ return unless proxy.is_a?(Hash) && proxy.keys.intersect?(%w[enabled global rules])
138
+
139
+ raise ConfigurationError, 'Shared proxy settings have moved to proxy.nodes; configure ' \
140
+ 'proxy.infrastructure separately'
141
+ end
142
+
143
+ def validate_egress_scope_migration!(egress)
144
+ return unless egress.is_a?(Array) || (egress.is_a?(Hash) && egress.key?('mode'))
145
+
146
+ raise ConfigurationError, 'Shared network.egress grants must be moved explicitly to ' \
147
+ 'network.egress.nodes or network.egress.infrastructure'
148
+ end
149
+
125
150
  def validate_operational!(data)
126
151
  validate_images!(data.fetch('images'))
127
152
  NodeImages.effective!(data.fetch('images'))
@@ -254,8 +254,10 @@ module Empeira
254
254
  end
255
255
 
256
256
  def resolve_direct_egress
257
- config = @plan.config.dig('network', 'egress')
258
- @direct_resolver.resolve(config)
257
+ entries = @plan.config.dig('network', 'egress').flat_map do |scope, grants|
258
+ @direct_resolver.resolve(grants).entries.map { |entry| entry.merge('scope' => scope) }
259
+ end
260
+ Network::DirectEgress::Resolved.new(entries)
259
261
  end
260
262
 
261
263
  def bootstrap_dns?(upstreams:, routes:)
@@ -636,6 +638,7 @@ module Empeira
636
638
  end
637
639
  reconcile('gateway')
638
640
  lockdown_gateway(@observed.fetch('gateway'))
641
+ require_scoped_egress_capability
639
642
  require_redirect_capability
640
643
  attach_gateway('gateway')
641
644
  apply_gateway_policy
@@ -668,6 +671,16 @@ module Empeira
668
671
  'images.direct_egress image'
669
672
  end
670
673
 
674
+ def require_scoped_egress_capability
675
+ return if @gateway_plan.fetch('entries').empty?
676
+
677
+ result = @runtime.service_exec(@observed.fetch('gateway'),
678
+ [Network::Gateway::EXECUTABLE, 'scoped-egress-capability'])
679
+ return if result.success? && result.stdout.strip == 'scoped-egress-v1'
680
+
681
+ raise Error, 'Gateway does not support scoped direct egress; configure a compatible images.direct_egress image'
682
+ end
683
+
671
684
  def apply_gateway_policy
672
685
  # An uncertain apply must force fresh lockdown during failure recovery.
673
686
  @gateway_locked_id = nil
@@ -116,10 +116,7 @@ module Empeira
116
116
  bindings = Network::ProxyBindings.new(context: @context, resources: resources, network: network, nodes: nodes)
117
117
  # The workspace parent is private; Squid's mapped service user must read the bind mount.
118
118
  rules_changed = write('proxy-rules.conf', bindings.configuration)
119
- allowed = proxy_allowed(nodes)
120
- addresses = resources.slice(*allowed).values.filter_map do |resource|
121
- resource&.dig('networks', network, 'IPAddress')
122
- end
119
+ addresses = bindings.addresses
123
120
  clients_changed = write('proxy-clients', "#{(['127.0.0.1/32'] + addresses.reject(&:empty?).sort).join("\n")}\n")
124
121
  rules_changed || clients_changed
125
122
  end
@@ -131,10 +128,6 @@ module Empeira
131
128
  Infrastructure::Definition.fingerprint(content)
132
129
  end
133
130
 
134
- def proxy_allowed(nodes)
135
- ['server', *nodes.keys]
136
- end
137
-
138
131
  private
139
132
 
140
133
  def corefile_content(upstreams:, routes:, bootstrap: false)
@@ -27,8 +27,7 @@ module Empeira
27
27
  end
28
28
 
29
29
  def proxy?
30
- egress = config.dig('network', 'egress')
31
- config.dig('proxy', 'enabled') || (egress.is_a?(Hash) && egress['mode'] == 'proxy')
30
+ %w[nodes infrastructure].any? { |scope| config.dig('proxy', scope, 'enabled') }
32
31
  end
33
32
 
34
33
  def database?
@@ -63,7 +62,7 @@ module Empeira
63
62
  def additional_services(common = {})
64
63
  config.dig('containers', 'additional').each_with_index.to_h do |entry, index|
65
64
  options = { image: image_reference(entry.fetch('image')), memory: 256,
66
- environment: proxy_environment_for('').merge(entry.fetch('environment', {})),
65
+ environment: infrastructure_proxy_environment.merge(entry.fetch('environment', {})),
67
66
  command: entry.fetch('command', []) }
68
67
  location = "containers.additional[#{index}].configuration"
69
68
  generated = additional_configurations.options(entry, mounts: common.fetch(:mounts, []), location: location)
@@ -175,9 +174,9 @@ module Empeira
175
174
  end
176
175
 
177
176
  def node_proxy_environment
178
- return {} unless proxy?
177
+ return {} unless config.dig('proxy', 'nodes', 'enabled')
179
178
 
180
- proxy_environment_for("http://#{naming.hostname('proxy')}:3128")
179
+ proxy_environment_for("http://#{naming.hostname('proxy')}:3128", scope: 'nodes')
181
180
  end
182
181
 
183
182
  private
@@ -209,9 +208,10 @@ module Empeira
209
208
  def database_services(common)
210
209
  return {} unless database?
211
210
 
212
- { 'postgres' => service('postgres', **common, **postgres),
213
- 'puppetdb-backend' => service('puppetdb-backend', **common, **puppetdb),
214
- 'puppetdb' => service('puppetdb', **common, **puppetdb_relay(dns: common[:dns])) }
211
+ options = common.merge(environment: infrastructure_proxy_environment)
212
+ { 'postgres' => service('postgres', **options, **postgres),
213
+ 'puppetdb-backend' => service('puppetdb-backend', **options, **puppetdb),
214
+ 'puppetdb' => service('puppetdb', **options, **puppetdb_relay(dns: common[:dns])) }
215
215
  end
216
216
 
217
217
  def server
@@ -271,15 +271,16 @@ module Empeira
271
271
  reports: database? ? 'puppetdb' : 'log',
272
272
  # Upstream entrypoints otherwise rewrite puppetdb.conf from their default URL.
273
273
  puppetdb_server_urls: ''
274
- ).merge(proxy_environment)
274
+ ).merge(infrastructure_proxy_environment)
275
275
  end
276
276
 
277
- def proxy_environment
278
- proxy_environment_for(proxy? ? "http://#{naming.hostname('proxy')}:3128" : '')
277
+ def infrastructure_proxy_environment
278
+ enabled = config.dig('proxy', 'infrastructure', 'enabled')
279
+ proxy_environment_for(enabled ? "http://#{naming.hostname('proxy')}:3128" : '', scope: 'infrastructure')
279
280
  end
280
281
 
281
- def proxy_environment_for(url)
282
- bypass = Network::DirectEgress.destinations(config.dig('network', 'egress'))
282
+ def proxy_environment_for(url, scope:)
283
+ bypass = Network::DirectEgress.destinations(config.dig('network', 'egress', scope))
283
284
  no_proxy = naming.no_proxy(bypass)
284
285
  { 'http_proxy' => url, 'https_proxy' => url, 'HTTP_PROXY' => url, 'HTTPS_PROXY' => url,
285
286
  'all_proxy' => '', 'ALL_PROXY' => '', 'no_proxy' => no_proxy, 'NO_PROXY' => no_proxy }
@@ -289,7 +290,8 @@ module Empeira
289
290
  { image: image_reference(config.dig('images', 'postgres')), memory: 256,
290
291
  env_file: files.secret_path,
291
292
  configuration: files.configuration.fetch('database-setup.sql'),
292
- environment: { 'POSTGRES_USER' => 'postgres', 'POSTGRES_DB' => 'puppetdb' },
293
+ environment: infrastructure_proxy_environment.merge('POSTGRES_USER' => 'postgres',
294
+ 'POSTGRES_DB' => 'puppetdb'),
293
295
  mounts: [storage('postgres-data', '/var/lib/postgresql/data'),
294
296
  bind(files.path('database-setup.sql'), '/empeira/setup.sql')],
295
297
  command: ['postgres', '-c', 'shared_buffers=64MB', '-c', 'max_connections=50'] }
@@ -24,7 +24,6 @@ module Empeira
24
24
  module_function
25
25
 
26
26
  def validate!(value, path = 'network.egress')
27
- return legacy!(value, path) if value.is_a?(Hash)
28
27
  unless value.is_a?(Array) && value.size <= MAX_HOSTS
29
28
  raise ConfigurationError, "#{path} must be an array with at most #{MAX_HOSTS} destination entries"
30
29
  end
@@ -33,12 +32,6 @@ module Empeira
33
32
  raise ConfigurationError, "#{path} contains duplicate destinations" unless hosts.uniq.size == hosts.size
34
33
  end
35
34
 
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
35
  def destinations(value)
43
36
  return [] unless value.is_a?(Array)
44
37
 
@@ -22,7 +22,15 @@ module Empeira
22
22
  { 'version' => 1, 'subnet' => subnet, 'gateway' => address(subnet),
23
23
  'dns' => address(subnet, 'dns'), 'resolvers' => resolvers,
24
24
  'proxies' => %w[proxy bootstrap-proxy].map { |key| address(subnet, key) },
25
- 'blocked' => blocked_addresses(state), 'entries' => resolved.entries, 'redirects' => redirects }
25
+ 'blocked' => blocked_addresses(state), 'entries' => scoped_entries(resolved, subnet),
26
+ 'redirects' => redirects }
27
+ end
28
+
29
+ def self.scoped_entries(resolved, subnet)
30
+ layout = Peer::Layout.new(subnet)
31
+ resolved.entries.map do |entry|
32
+ entry.except('scope').merge('sources' => layout.egress_sources(entry.fetch('scope')))
33
+ end
26
34
  end
27
35
 
28
36
  def self.blocked_addresses(state)
@@ -3,7 +3,7 @@
3
3
  module Empeira
4
4
  module Network
5
5
  class HostPolicy
6
- def self.validate!(rules, path = 'proxy.rules')
6
+ def self.validate!(rules, path = 'proxy.nodes.rules')
7
7
  raise ConfigurationError, "#{path} must be an array" unless rules.is_a?(Array)
8
8
 
9
9
  rules.each_with_index { |rule, index| validate_rule!(rule, "#{path}.#{index}") }
@@ -28,6 +28,14 @@ module Empeira
28
28
  "#{address(128)}/25"
29
29
  end
30
30
 
31
+ def egress_sources(scope)
32
+ case scope
33
+ when 'nodes' then ["#{address(32)}/27", "#{address(64)}/26"]
34
+ when 'infrastructure' then [container_pool]
35
+ else raise Error, 'Unknown direct-egress scope'
36
+ end
37
+ end
38
+
31
39
  def lease(nodes)
32
40
  used = nodes.values.filter_map { |record| record.dig('peer', 'ip') }
33
41
  ip = (32..95).map { |offset| address(offset) }.find { |candidate| !used.include?(candidate) }
@@ -12,22 +12,39 @@ module Empeira
12
12
  end
13
13
 
14
14
  def configuration
15
- entries = %w[server].map do |name|
16
- rules(name.tr('-', '_'), address(name), @config.fetch('global'))
15
+ entries = bindings.filter_map do |label, hostname, domains|
16
+ rules(label, address(hostname), domains)
17
17
  end
18
- @nodes.sort.each_with_index do |(hostname, record), index|
19
- entries << node_rules(hostname, record, index)
20
- end
21
- "#{entries.compact.join("\n")}\n"
18
+ "#{entries.join("\n")}\n"
19
+ end
20
+
21
+ def addresses
22
+ bindings.filter_map { |_, hostname, _| address(hostname) }.uniq
22
23
  end
23
24
 
24
25
  private
25
26
 
26
- def node_rules(hostname, record, index)
27
- return if record['network_phase'] == 'bootstrap'
27
+ def bindings
28
+ infrastructure_bindings + node_bindings
29
+ end
30
+
31
+ def infrastructure_bindings
32
+ scope = @config.fetch('infrastructure')
33
+ return [] unless scope.fetch('enabled')
28
34
 
29
- domains = HostPolicy.new(@config).resolve(hostname)
30
- rules("node_#{index}", address(hostname), domains)
35
+ names = %w[server postgres puppetdb-backend puppetdb] +
36
+ @context.configuration.dig('containers', 'additional').map { |service| service.fetch('name') }
37
+ names.map { |name| [name.tr('-', '_'), name, scope.fetch('allow')] }
38
+ end
39
+
40
+ def node_bindings
41
+ scope = @config.fetch('nodes')
42
+ return [] unless scope.fetch('enabled')
43
+
44
+ policy = HostPolicy.new(scope)
45
+ @nodes.sort.each_with_index.filter_map do |(hostname, record), index|
46
+ ["node_#{index}", hostname, policy.resolve(hostname)] unless record['network_phase'] == 'bootstrap'
47
+ end
31
48
  end
32
49
 
33
50
  def address(name)
@@ -63,9 +63,9 @@ module Empeira
63
63
 
64
64
  naming = Network::Naming.new
65
65
  internal = naming.reserved(@context.configuration).flat_map { |name| naming.aliases(name) }
66
+ bypass = Network::DirectEgress.destinations(@context.configuration.dig('network', 'egress', 'nodes'))
66
67
  { 'url' => environment.fetch('http_proxy'), 'no_proxy' => environment.fetch('no_proxy'),
67
- 'direct' => ['localhost', '127.0.0.1', *internal,
68
- *Network::DirectEgress.destinations(@context.configuration.dig('network', 'egress'))].uniq }
68
+ 'direct' => ['localhost', '127.0.0.1', *internal, *bypass].uniq }
69
69
  end
70
70
 
71
71
  def execute(desired, previous)
@@ -3,6 +3,7 @@
3
3
  module Empeira
4
4
  module Runtime
5
5
  class ServiceArguments
6
+ PROXY_VARIABLES = %w[http_proxy https_proxy all_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY no_proxy NO_PROXY].freeze
6
7
  SCALARS = { 'dns' => '--dns', 'user' => '--user', 'entrypoint' => '--entrypoint',
7
8
  'env_file' => '--env-file', 'shm_size' => '--shm-size', 'mac_address' => '--mac-address',
8
9
  'ip' => '--ip', 'runtime_ip' => '--ip' }.freeze
@@ -20,7 +21,7 @@ module Empeira
20
21
  '--network', @options.fetch('network'), '--memory', "#{@options.fetch('memory')}m",
21
22
  '--cpus', @options.fetch('cpus', 1).to_s, '--no-healthcheck',
22
23
  *init_options, *security_options, *pairs('--label', @definition.labels), *scalar_options,
23
- *pairs('--env', @options.fetch('environment', {}).merge(@options.fetch('runtime_environment', {}))),
24
+ *pairs('--env', environment),
24
25
  *pairs('--sysctl', @options.fetch('sysctls', {})),
25
26
  *@options.fetch('mounts', []).flat_map { |mount| ['--mount', mount] },
26
27
  *@options.fetch('ports', []).flat_map { |port| ['--publish', port] },
@@ -29,6 +30,11 @@ module Empeira
29
30
 
30
31
  private
31
32
 
33
+ def environment
34
+ PROXY_VARIABLES.to_h { |key| [key, ''] }.merge(@options.fetch('environment', {}))
35
+ .merge(@options.fetch('runtime_environment', {}))
36
+ end
37
+
32
38
  def security_options
33
39
  values = { 'cap_drop' => '--cap-drop', 'cap_add' => '--cap-add', 'devices' => '--device',
34
40
  'security_options' => '--security-opt' }
@@ -111,17 +111,13 @@ module EmpeiraGateway
111
111
  end
112
112
 
113
113
  def direct_rules(plan, internal, external)
114
- filter = []
115
- plan.fetch('entries').each do |entry|
116
- entry.fetch('addresses').each do |ip|
117
- entry.fetch('ports').each do |port|
118
- filter << "-A FORWARD -i #{internal} -o #{external} -s #{plan.fetch('subnet')} " \
119
- "-d #{ip}/32 -p tcp --dport #{port} -j ACCEPT"
120
- filter << reply(internal, external, destination: ip, port: port)
121
- end
114
+ plan.fetch('entries').flat_map do |entry|
115
+ entry.fetch('sources').product(entry.fetch('addresses'), entry.fetch('ports')).flat_map do |source, ip, port|
116
+ ["-A FORWARD -i #{internal} -o #{external} -s #{source} " \
117
+ "-d #{ip}/32 -p tcp --dport #{port} -j ACCEPT",
118
+ reply(internal, external, source: source, destination: ip, port: port)]
122
119
  end
123
120
  end
124
- filter
125
121
  end
126
122
 
127
123
  def reply(internal, external, port:, source: nil, destination: nil, protocol: 'tcp')
@@ -145,7 +141,7 @@ module EmpeiraGateway
145
141
  raise 'Invalid workspace subnet' unless subnet.ipv4? && subnet.private? && subnet.prefix == 24
146
142
 
147
143
  validate_addresses(plan, subnet)
148
- validate_entries(plan.fetch('entries'))
144
+ validate_entries(plan.fetch('entries'), subnet)
149
145
  validate_redirects(plan.fetch('redirects', []), subnet)
150
146
  end
151
147
 
@@ -197,14 +193,27 @@ module EmpeiraGateway
197
193
  %w[resolvers proxies blocked].each { |key| plan.fetch(key, []).each { |ip| ipv4(ip) } }
198
194
  end
199
195
 
200
- def validate_entries(entries)
196
+ def validate_entries(entries, subnet)
201
197
  entries.each do |entry|
198
+ validate_sources(entry.fetch('sources'), subnet)
202
199
  entry.fetch('addresses').each { |ip| ipv4(ip) }
203
200
  valid = entry.fetch('ports').all? { |port| port.is_a?(Integer) && port.between?(1, 65_535) }
204
201
  raise 'Invalid gateway ports' unless valid
205
202
  end
206
203
  end
207
204
 
205
+ def validate_sources(sources, subnet)
206
+ valid = sources.is_a?(Array) && !sources.empty? && sources.all? { |source| valid_source?(source, subnet) }
207
+ raise 'Invalid direct-egress source ranges' unless valid
208
+ end
209
+
210
+ def valid_source?(source, subnet)
211
+ range = IPAddr.new(source)
212
+ range.ipv4? && range.prefix > subnet.prefix && subnet.include?(range) && "#{range}/#{range.prefix}" == source
213
+ rescue IPAddr::InvalidAddressError, TypeError
214
+ false
215
+ end
216
+
208
217
  def ipv4_interfaces
209
218
  Socket.getifaddrs.select { |entry| entry.addr&.ipv4? && !entry.addr.ipv4_loopback? }
210
219
  end
@@ -301,6 +310,7 @@ module EmpeiraGateway
301
310
  when 'redirects-capability'
302
311
  command('conntrack', '--version')
303
312
  puts 'tcp-udp-redirects-v2'
313
+ when 'scoped-egress-capability' then puts 'scoped-egress-v1'
304
314
  when 'lockdown'
305
315
  restore(lockdown)
306
316
  when 'apply' then apply(arguments.fetch(1))
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: empeira
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.pre.alpha.4
4
+ version: 0.1.0.pre.alpha.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christian Meißner (c8m6)