empeira 0.1.0.pre.alpha.3 → 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: c856b6d473f798e1882b261f6dec67a20ebaa89142a073ba9b977265cd24cd6f
4
- data.tar.gz: ac23a47ef8ecf4abfdd8d48557cee762f5954e69c40c335b12e7d6428e376709
3
+ metadata.gz: 6a6bd2bd179b9a3fa789f2342e0646d9e5d681941cd2df234770cf8a94a01f31
4
+ data.tar.gz: 8925fd4ded29c7206cfe6c1d2d072f7a41a649582504dbf1a29232543525aacf
5
5
  SHA512:
6
- metadata.gz: 30b50d1c33a73512fdd2067064e0f17b63dda84204045b90994137ec79ee8374c466501b232119ea2fff3b81f9dda39c62edd8fed47b5ee50a528f75f063dbf6
7
- data.tar.gz: 8daa8af9f4f9bb5ffcc1751cf3c8714e6853142dd59878ba6791150b79511549b626cc3edd9eafc95e36adfed3b00f793e8f97d78a607ac6d29dd70865e91dc4
6
+ metadata.gz: 988ab09ce7e604a6bddfe1f5acad7f2847939b96e33ad6b73d06a03d49a6aafc7048222f5bb2a26eddb412dd75442b6d57addddd9558cb974a675603c11a1fb6
7
+ data.tar.gz: 6d5c737b4f18f0d400f279c59b0b10f7e68bb3b564c649b42207e0f447ba7b68a2acd93b34258ca7d025252d91de4c2befcfc741b85195f23a8b8feecaa99e46
data/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![OpenVox E2E](https://img.shields.io/github/actions/workflow/status/c8m6/empeira/openvox.yml?branch=main&label=OpenVox%20E2E)](https://github.com/c8m6/empeira/actions/workflows/openvox.yml)
7
7
  [![Release](https://img.shields.io/github/v/release/c8m6/empeira?include_prereleases&sort=date&label=Release)](https://github.com/c8m6/empeira/releases)
8
8
  [![Ruby: 3.1–4.0](https://img.shields.io/badge/Ruby-3.1%20%7C%203.2%20%7C%203.3%20%7C%203.4%20%7C%204.0-CC342D)](docs/installation.md)
9
- [![License: AGPL-3.0-only](https://img.shields.io/badge/License-AGPL--3.0--only-blue)](LICENSE)
9
+ [![License: AGPL-3.0-or-later](https://img.shields.io/badge/License-AGPL--3.0--or--later-blue)](LICENSE)
10
10
 
11
11
  **ἐμπειρία (empeiría)** — experience gained through practice. The name reflects
12
12
  learning about infrastructure by experimenting, testing and seeing what happens.
@@ -149,6 +149,6 @@ and [pull requests](https://github.com/c8m6/empeira/pulls) are welcome. Follow t
149
149
  [security policy](SECURITY.md).
150
150
 
151
151
  Project-owned code and documentation are licensed under
152
- [AGPL-3.0-only](LICENSE). Third-party packages and images retain their own licenses
152
+ [AGPL-3.0-or-later](LICENSE). Third-party packages and images retain their own licenses
153
153
  and notices and are obtained from upstream; empeira does not bundle them or
154
154
  redistribute proprietary Puppet Enterprise software.
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
 
data/docs/architecture.md CHANGED
@@ -184,9 +184,12 @@ there are no implicit database-major migrations or automatic node replacements.
184
184
 
185
185
  Managed servers disable environment caching through native Puppet configuration
186
186
  (`environment_timeout = 0` in `[server]`), applied by the startup wrapper before
187
- the configured image entrypoint. `up` checks the actual native setting and restarts
188
- only a server whose setting changed; it preserves every other configuration key
189
- and retained storage. The configured Puppet confdir must agree with Puppetserver
187
+ the configured image entrypoint. An independent `environment.conf` copy in the
188
+ managed environment enforces zero even when the control repository specifies
189
+ `unlimited`; the original remains unchanged and read-only. `up` refreshes the copy,
190
+ checks both native and selected-environment settings, and restarts only a server
191
+ whose native setting changed. It preserves every other configuration key and
192
+ retained storage. The configured Puppet confdir must agree with Puppetserver
190
193
  HOCON `master-conf-dir`. Catalog
191
194
  requests reload live code without repository hashes or environment-cache checkpoints.
192
195
  See [nodes](nodes.md#environment-cache-and-live-code).
@@ -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 |
@@ -404,20 +413,25 @@ runtime values without inferring them from the image name. Missing or unsafe
404
413
  runtime fields fail configuration validation. Managed servers use
405
414
  `environment_timeout = 0` in the native `[server]` section. The startup wrapper
406
415
  uses `puppet config set` before the configured entrypoint, independently of image
407
- environment-variable mappings. Startup and `up` read Puppet's native server
408
- settings without selecting an environment override, against the
409
- confdir containing `server.runtime.paths.puppetdb_config`; custom Puppetserver
416
+ environment-variable mappings. Empeira also prepares an independent copy of the
417
+ project's `environment.conf` in its managed environment directory, replacing or
418
+ adding only `environment_timeout = 0`. Other settings, including `modulepath` and
419
+ `config_version`, are preserved. The original file and the control repository
420
+ remain read-only in the server. The copy is refreshed during `up`, including when
421
+ the project has no `environment.conf` or Hiera mounts.
422
+
423
+ `up` verifies both the native server setting and the effective timeout for
424
+ `server.environment`, against the confdir containing
425
+ `server.runtime.paths.puppetdb_config`; custom Puppetserver
410
426
  `jruby-puppet.master-conf-dir` must refer to that same directory. Puppetserver
411
- HOCON master directories take precedence over Puppet CLI defaults. A changed
412
- effective timeout updates only that key and restarts the same owned server; an
427
+ HOCON master directories take precedence over Puppet CLI defaults. Native setting
428
+ drift updates only that key and restarts the same owned server; an
413
429
  entrypoint that overwrites it fails verification. CA, SSL and database storage
414
430
  are preserved. The next catalog request reloads live code without
415
- an Empeira content scan or admin API invalidation. An explicit `environment.conf`
416
- can override this server setting; keep its timeout at zero for immediate edits.
417
- Such overrides are preserved and do not prevent server startup or cause repeated
418
- restarts. `puppet config print environment_timeout --section server` includes the
419
- selected environment's override, so it can report `unlimited` while the native
420
- server setting is zero. Inspect both configuration layers when diagnosing caching.
431
+ an Empeira content scan or admin API invalidation. A project timeout of `unlimited`
432
+ therefore stays unchanged in the checkout while the managed server uses zero.
433
+ To verify it, run `puppet config print environment_timeout --section server
434
+ --environment ENVIRONMENT` inside the server with the configured environment name.
421
435
 
422
436
  `puppetdb.runtime` holds the independent OpenVoxDB process contract. Custom
423
437
  images may override its executable, numeric user, Java and main arguments,
@@ -483,14 +497,20 @@ The numeric gem packaging fallback is never used to bypass this check.
483
497
  ```yaml
484
498
  network:
485
499
  egress:
486
- - host: api.example.com
487
- ports: [443]
500
+ nodes:
501
+ - host: api.example.com
502
+ ports: [443]
503
+ infrastructure: []
488
504
  proxy:
489
- enabled: true
490
- global: [forgeapi.puppet.com]
491
- rules:
492
- - hosts: ["*-web-*"]
493
- 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: []
494
514
  hiera:
495
515
  mounts:
496
516
  - source: ../hieradata
@@ -502,7 +522,12 @@ vm:
502
522
  root_password: null
503
523
  ```
504
524
 
505
- 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.
506
531
  After bootstrap cleanup, Empeira reconciles normal proxy settings on running,
507
532
  provisioned nodes during `up` and `node start`. Repeated Puppet runs use the
508
533
  existing guest configuration. No
@@ -714,21 +739,24 @@ desired state; Puppet owns package state after the first catalog.
714
739
 
715
740
  ## Direct egress
716
741
 
717
- `network.egress` contains destination TCP grants, independent of HTTP/HTTPS proxy
718
- 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`
719
745
  array (1–65535). `up` resolves all A records and replaces the complete policy;
720
746
  resolution/apply failure blocks egress. IPv6 and external UDP grants are unsupported.
721
747
 
722
748
  ```yaml
723
749
  network:
724
750
  egress:
725
- - host: repository.example.net
726
- ports: [443]
727
- - ip: 203.0.113.10
728
- 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: []
729
757
  ```
730
758
 
731
- 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
732
760
  or proxy themselves. See [networking](networking.md#one-gateway-one-direct-egress-policy)
733
761
  for routing, revocation and privilege boundaries.
734
762
 
@@ -760,7 +788,7 @@ vm:
760
788
  network: 192.0.2.20/32
761
789
  ```
762
790
 
763
- 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
764
792
  node hostname, case-insensitively. Patterns within a rule are alternatives. All
765
793
  matching rules contribute devices; identical definitions merge once. A conflicting
766
794
  definition names the hostname, device and both configuration paths. No match creates
data/docs/development.md CHANGED
@@ -253,6 +253,7 @@ The APT authentication regression uses a local HTTPS repository with synthetic B
253
253
  credentials and an ephemeral GPG signing key. It checks the helper's effective
254
254
  `auth.conf.d` configuration, private file mode and exact credential bytes, then
255
255
  verifies metadata returns 401 before one interactive login and 200 after the retry.
256
+ The fixture omits the 401 reason phrase to exercise APT's bare status/IP diagnostic.
256
257
  The selected package download must reuse that login and pass its SHA-256 check.
257
258
  It requires host `gpg`/`gpgconf` and a locally reachable container engine; helper and
258
259
  host TLS verification remain enabled. Run it explicitly for either engine:
@@ -511,9 +512,10 @@ Run `EMPEIRA_INTEGRATION=server-environment bundle exec rspec spec/integration/s
511
512
  to check native timeout 0, live edits, CA retention, PuppetDB and idempotent up
512
513
  under Docker/rootless Podman with the default server and a custom entrypoint
513
514
  that ignores timeout environment mapping.
514
- The same gate verifies startup and idempotent `up` with an explicit
515
- `environment.conf` timeout override, preserving the file and distinguishing its
516
- effective value from the native server setting.
515
+ The same gate verifies that an explicit `environment.conf` timeout of `unlimited`
516
+ is zero in the managed copy while the original stays unchanged. It edits a Ruby
517
+ Puppet function between catalogs and checks the changed result without `up` or a
518
+ restart, followed by an idempotent `up`.
517
519
 
518
520
  Run the isolated diagnostics/recovery gate with each native runtime:
519
521
 
@@ -105,7 +105,8 @@ An optional mount is not permission to ignore unsafe configuration.
105
105
  Puppet's [default modulepath](https://help.puppet.com/core/current/Content/PuppetCore/config_file_environment.htm)
106
106
  includes the environment's `modules` directory. Empeira checks explicit
107
107
  `environment.conf` modulepaths and reports a known exclusion before startup. It
108
- never rewrites that file. Expressions containing unresolved Puppet variables cannot
108
+ never rewrites the original file. Its managed copy changes only
109
+ `environment_timeout` to zero. Expressions containing unresolved Puppet variables cannot
109
110
  be verified completely from the CLI host; ensure the effective server modulepath
110
111
  includes the mount destination. Environment mounts can be referenced by ordinary
111
112
  relative paths in `hiera.yaml`, such as `data/external` or `external` when the datadir
@@ -117,7 +118,8 @@ an image rebuild, `up`, or a service restart.
117
118
  Empeira neither copies their data nor writes probe files into them. It uses a managed
118
119
  environment directory containing links to the read-only control repository so nested
119
120
  mount targets can exist without modifying control code. Existing control files and
120
- linked directories remain live. `up` refreshes links for new control entries under
121
+ linked directories remain live; `environment.conf` is an independent copy refreshed
122
+ by `up`. `up` also refreshes links for new top-level control entries and entries under
121
123
  projected ancestor directories. EYAML keys remain configured separately in `eyaml`,
122
124
  and decryption runs on the server.
123
125
 
@@ -205,8 +207,8 @@ The obsolete `images.modules` key is rejected.
205
207
  The disposable installer belongs to the update plane. It uses the selected
206
208
  runtime's normal bridge network (`bridge` on Docker, `podman` on Podman), with
207
209
  normal runtime DNS and external connectivity. It never joins an Empeira workspace
208
- network and needs neither CoreDNS nor Squid. Workspace `network.egress`, `proxy.enabled`,
209
- `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
210
212
  Forge or Git acquisition. Supplied `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and their
211
213
  lowercase equivalents are inherited unchanged; Empeira generates no proxy URL.
212
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/nodes.md CHANGED
@@ -87,18 +87,22 @@ Managed servers enforce native `[server] environment_timeout = 0` before startup
87
87
  and verify the effective setting during `up`, independently of image environment
88
88
  variable support. Existing workspaces adopt it on
89
89
  their next `up`; run that once after updating from a version that enabled caching.
90
+ Adopting the managed environment copy changes the server mount and recreates its
91
+ container once, retaining CA, SSL and database storage.
90
92
  Each catalog request reloads live control code, modules and mounted Hiera data.
91
93
  Empeira does not hash these trees,
92
94
  persist content checkpoints or call the environment-cache admin API. Code edits
93
95
  need neither `up` nor a server restart, including for agents invoked manually.
94
96
  Empeira does not enable `use_cached_catalog`.
95
97
 
96
- A repository's own `environment.conf` can override the server timeout; use zero
97
- for immediate edits. Avoid concurrent edits during a catalog run. Existing files
98
- and directories in projected environments stay live through read-only links.
99
- New siblings under ancestors projected for nested Hiera mounts require `up` to
100
- create their links. `node start` resumes the node without invoking Puppet; Puppet
101
- services enabled by a catalog may run independently on startup.
98
+ Empeira copies `environment.conf` into its managed environment and sets its timeout
99
+ to zero there, preserving all other settings and the original file. Run `up` to
100
+ refresh this copy after editing other environment settings. Avoid concurrent edits
101
+ during a catalog run. Existing files and directories stay live through read-only
102
+ links. New top-level entries and new siblings under ancestors projected for nested
103
+ Hiera mounts require `up` to create their links. `node start` resumes the node
104
+ without invoking Puppet; Puppet services enabled by a catalog may run independently
105
+ on startup.
102
106
 
103
107
  ## Distribution package sources
104
108
 
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
@@ -33,6 +33,14 @@ HTTP 403 does not trigger login, and HTTP 407 identifies proxy authentication. R
33
33
  authentication continues to use native `docker login` / `podman login` and the reported
34
34
  registry host; Empeira does not log into registries automatically.
35
35
 
36
+ APT can report an HTTP status without a reason phrase, for example
37
+ `401 [IP: 192.0.2.1 443]`. This still triggers the single repository login attempt
38
+ when no ENV credentials are set and both standard input and standard error are
39
+ connected to a TTY. The corresponding `403` and `407` forms retain their access-denied
40
+ and proxy-authentication diagnoses without prompting. A node retained as `incomplete`
41
+ after an acquisition failure must be destroyed with `empeira node destroy HOSTNAME`
42
+ and recreated with the original `empeira node run` arguments after resolving the cause.
43
+
36
44
  APT helper credentials use literal `login` and `password` tokens in a temporary
37
45
  0600 file under `/etc/apt/auth.conf.d`. APT treats surrounding quotes and added
38
46
  backslashes as credential bytes, so Empeira does not add them. A successful login
@@ -124,7 +132,7 @@ or restart of that same VM conflicts with the shared instance guard: detach with
124
132
  ## Proxy destination denied
125
133
 
126
134
  Run `empeira proxy show HOSTNAME` and check every matching rule. Verify the node
127
- 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
128
136
  configuration and current node addresses. Allowed DNS destinations may resolve to
129
137
  private or reserved IP addresses; only HTTP port 80 and HTTPS CONNECT port 443 are
130
138
  permitted. After updating Empeira, run `empeira up` to replace a proxy still using
@@ -140,11 +148,14 @@ problem. A successful probe with different directory listings indicates a
140
148
  different engine view of the source. Empeira does not weaken mount checks or
141
149
  change host permissions automatically.
142
150
 
143
- An explicit `environment_timeout` in the project's `environment.conf` overrides
144
- the native server setting. Empeira preserves it. Bare `puppet config print`
145
- includes that override; server startup and reconciliation verify only the native
146
- server value. Set the environment override to zero or remove it when immediate
147
- code reloads are required. No explicit cache invalidation is performed.
151
+ Empeira enforces `environment_timeout = 0` in both the native server setting and
152
+ its independent copy of `environment.conf`. The project's original file remains
153
+ unchanged, even when it specifies `unlimited`. Run `up` after updating Empeira or
154
+ editing other settings in `environment.conf` to refresh the managed copy. Inside
155
+ the server, `puppet config print environment_timeout --section server --environment
156
+ ENVIRONMENT` must report `0` for the configured environment. A different value
157
+ fails startup verification; check the custom server's confdir and environmentpath.
158
+ No explicit cache invalidation is performed.
148
159
 
149
160
  `config show` includes `effective_hiera_mounts`. A skipped optional source is
150
161
  reported during `up`; mount it locally or correct the source path, then run `up`
@@ -356,6 +367,6 @@ external runtime network as a workaround. Docker workspace bridge attachment
356
367
  errors require an accessible Linux-engine host namespace and an identifiable
357
368
  iptables backend. The helper changes only owned bridge rules. Do not disable the
358
369
  host firewall globally. Check Docker Desktop host-network restrictions separately.
359
- 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`,
360
371
  not adding unrelated direct grants. Corporate DNS still follows host discovery;
361
372
  IPv6-only upstreams must be replaced with reachable IPv4 resolvers for this backend.
@@ -39,11 +39,11 @@ module Empeira
39
39
  diagnostic = Execution::Diagnostics.native(result.with(stderr: text, stdout: ''),
40
40
  operation: "Agent #{operation}", tool: arguments.first)
41
41
  case text
42
- when /407\s+Proxy Authentication Required|(?:HTTP|Status code|HTTP code)\s*:?\s*407\b/i
42
+ when /407\s+Proxy Authentication Required|(?:HTTP|Status code|HTTP code)\s*:?\s*407\b|\b407[ \t]+\[IP:/i
43
43
  raise Error, "Host helper proxy authentication failed\n#{diagnostic}", cause: nil
44
- when /401\s+Unauthorized|(?:HTTP|Status code|HTTP code)\s*:?\s*401\b/i
44
+ when /401\s+Unauthorized|(?:HTTP|Status code|HTTP code)\s*:?\s*401\b|\b401[ \t]+\[IP:/i
45
45
  raise Agent::AuthenticationRequired, diagnostic, cause: nil
46
- when /403\s+Forbidden|(?:HTTP|Status code|HTTP code)\s*:?\s*403\b/i
46
+ when /403\s+Forbidden|(?:HTTP|Status code|HTTP code)\s*:?\s*403\b|\b403[ \t]+\[IP:/i
47
47
  raise Error, "Agent repository access denied\n#{diagnostic}", cause: nil
48
48
  end
49
49
  raise Error, "Agent #{operation} failed\n#{diagnostic}", cause: nil
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-alpha.3",
3
- "revision": "bfada39f711fd74dae3f061f80e1c5293bd13eec",
4
- "build_time": "2026-10-15T13:10:56Z"
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:)
@@ -350,10 +352,11 @@ module Empeira
350
352
 
351
353
  def reconcile_server_environment
352
354
  environment = Server::EnvironmentConfiguration.new(runtime: @runtime, plan: @plan)
353
- return unless environment.reconcile(@observed.fetch('server'))
355
+ if environment.reconcile(@observed.fetch('server'))
356
+ @changed = true
357
+ @health.wait('server', @observed)
358
+ end
354
359
 
355
- @changed = true
356
- @health.wait('server', @observed)
357
360
  environment.verify!(@observed.fetch('server'))
358
361
  end
359
362
 
@@ -635,6 +638,7 @@ module Empeira
635
638
  end
636
639
  reconcile('gateway')
637
640
  lockdown_gateway(@observed.fetch('gateway'))
641
+ require_scoped_egress_capability
638
642
  require_redirect_capability
639
643
  attach_gateway('gateway')
640
644
  apply_gateway_policy
@@ -667,6 +671,16 @@ module Empeira
667
671
  'images.direct_egress image'
668
672
  end
669
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
+
670
684
  def apply_gateway_policy
671
685
  # An uncertain apply must force fresh lockdown during failure recovery.
672
686
  @gateway_locked_id = nil
@@ -9,7 +9,7 @@ module Empeira
9
9
  def initialize(context:, hiera:)
10
10
  @project = context.project.path
11
11
  @root = root(context)
12
- @targets = mount_targets(hiera)
12
+ @targets = ['environment.conf', *mount_targets(hiera)]
13
13
  @modules = Modules::Storage.new(context: context).root if @project.join('Puppetfile').file?
14
14
  @targets |= ['modules'] if @modules
15
15
  @base = context.locations.workspace(context.workspace).join('environments')
@@ -17,14 +17,11 @@ module Empeira
17
17
  end
18
18
 
19
19
  def mounts
20
- return [bind(@project, @root)] if @targets.empty?
21
-
22
20
  [bind(@project, CONTROL), bind(@directory, @root),
23
21
  *(@modules ? [bind(@modules, "#{@root}/modules")] : [])]
24
22
  end
25
23
 
26
24
  def prepare
27
- return if @targets.empty?
28
25
  raise Error, 'Managed environment directory must not be a symlink' if @directory.symlink?
29
26
 
30
27
  FileUtils.mkdir_p(@directory, mode: 0o755)
@@ -81,13 +78,36 @@ module Empeira
81
78
  end
82
79
 
83
80
  def create_entry(relative, destination)
84
- if @targets.any? { |target| target == relative || target.start_with?("#{relative}/") }
81
+ if relative == 'environment.conf'
82
+ prepare_configuration(destination)
83
+ elsif @targets.any? { |target| target == relative || target.start_with?("#{relative}/") }
85
84
  create_target(relative, destination)
86
85
  else
87
86
  create_link(relative, destination)
88
87
  end
89
88
  end
90
89
 
90
+ def prepare_configuration(destination)
91
+ content = environment_configuration
92
+ return if !destination.symlink? && destination.file? && destination.binread == content
93
+
94
+ Tempfile.create('environment-', @directory) do |file|
95
+ file.chmod(0o644)
96
+ file.write(content)
97
+ file.flush
98
+ File.rename(file.path, destination)
99
+ end
100
+ end
101
+
102
+ def environment_configuration
103
+ source = @project.join('environment.conf')
104
+ content = source.exist? ? source.binread : ''
105
+ content = content.gsub(/^[ \t]*environment_timeout[ \t]*=[^\r\n]*/, 'environment_timeout = 0')
106
+ content += "\n" unless content.empty? || content.end_with?("\n")
107
+ content += "environment_timeout = 0\n" unless content.match?(/^environment_timeout = 0\r?$/)
108
+ content
109
+ end
110
+
91
111
  def create_target(relative, destination)
92
112
  raise Error, 'Managed environment destination is unsafe' if destination.symlink?
93
113
 
@@ -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' }
@@ -24,10 +24,10 @@ module Empeira
24
24
  end
25
25
 
26
26
  def verify!(resource)
27
- return if current(resource) == '0'
27
+ return if current(resource) == '0' && effective(resource) == '0'
28
28
 
29
29
  raise Error, 'Puppetserver environment_timeout is not 0 after startup; the custom entrypoint or ' \
30
- 'server confdir overrides the managed setting; inspect the server runtime contract'
30
+ 'server confdir/environment overrides the managed setting; inspect the server runtime contract'
31
31
  end
32
32
 
33
33
  private
@@ -36,6 +36,12 @@ module Empeira
36
36
  execute(resource, [ControlPlane::Health::RUBY, READER, '--confdir', confdir]).stdout.strip
37
37
  end
38
38
 
39
+ def effective(resource)
40
+ arguments = [PUPPET, 'config', 'print', 'environment_timeout', '--section', 'server',
41
+ '--confdir', confdir, '--environment', @plan.config.dig('server', 'environment')]
42
+ execute(resource, arguments).stdout.strip
43
+ end
44
+
39
45
  def execute(resource, arguments)
40
46
  result = @runtime.service_exec(resource, arguments)
41
47
  return result if result.success?
@@ -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.3
4
+ version: 0.1.0.pre.alpha.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christian Meißner (c8m6)
@@ -304,7 +304,7 @@ files:
304
304
  - resources/relay/server_start.sh
305
305
  homepage: https://github.com/c8m6/empeira
306
306
  licenses:
307
- - AGPL-3.0-only
307
+ - AGPL-3.0-or-later
308
308
  metadata:
309
309
  rubygems_mfa_required: 'true'
310
310
  rdoc_options: []