@le-space/rootfs 0.9.9 → 0.11.0

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.
package/index.d.ts CHANGED
@@ -166,6 +166,8 @@ interface RootfsExecutionPlan {
166
166
  mode: "host" | "docker";
167
167
  reason: string;
168
168
  referenceRootfsDir: string;
169
+ /** Run on the host first: files the image needs, built into the reference dir (all a Docker build sees). */
170
+ assetCommands?: RootfsCommandPlan[];
169
171
  prepareCommand?: RootfsCommandPlan;
170
172
  runCommand: RootfsCommandPlan;
171
173
  }
@@ -176,6 +178,13 @@ interface RootfsExecutionPlanOptions {
176
178
  orbitdbRelayMountPath?: string;
177
179
  dockerImageTag?: string;
178
180
  }
181
+ /**
182
+ * The orbitdb-relay image carries the attest relay (packages/attest-relay) as
183
+ * one bundled file, so browsers can reach a confidential guest's RA-TLS port
184
+ * at wss://<relay host>/attest. Built from this workspace when it is here; an
185
+ * image built without it has no attest relay (build-rootfs-image.sh says so).
186
+ */
187
+ declare function profileAssetCommands(contract: RootfsContract, referenceRootfsDir: string): RootfsCommandPlan[];
179
188
  declare function createHostRootfsExecutionPlan(plan: RootfsBuildPlan, options?: RootfsExecutionPlanOptions): RootfsExecutionPlan;
180
189
  declare function createDockerRootfsExecutionPlan(plan: RootfsBuildPlan, options?: RootfsExecutionPlanOptions): RootfsExecutionPlan;
181
190
  declare function selectRootfsExecutionPlan(plan: RootfsBuildPlan, availability: RootfsToolchainAvailability, options?: RootfsExecutionPlanOptions): RootfsExecutionPlan;
@@ -261,4 +270,4 @@ declare function createRootfsScriptCommand(buildPlan: RootfsBuildPlan, reference
261
270
  declare function buildRootfs(buildPlan: RootfsBuildPlan, deps: RootfsCommandRunner, availability: RootfsToolchainAvailability, options?: RootfsExecutionPlanOptions): Promise<RootfsExecutionResult>;
262
271
  declare function publishRootfs(buildPlan: RootfsBuildPlan, deps: RootfsExecutorDependencies, options?: RootfsPublishOptions): Promise<RootfsPublishExecutionResult>;
263
272
 
264
- export { ITEM_HASH_RE, type RootfsBuildDriver, type RootfsBuildOptions, type RootfsBuildPipeline, type RootfsBuildPlan, type RootfsCommandPlan, type RootfsCommandRunner, type RootfsContract, type RootfsContractManifest, type RootfsContractPort, type RootfsContractRootfs, type RootfsContractServices, type RootfsContractSource, type RootfsContractState, type RootfsExecutedCommand, type RootfsExecutionPlan, type RootfsExecutionPlanOptions, type RootfsExecutionResult, type RootfsExecutorDependencies, type RootfsFileReader, type RootfsFinalizeOptions, type RootfsFinalizeResult, type RootfsIpfsAddEntry, type RootfsManifest, type RootfsManifestOptions, type RootfsManifestOutputPaths, type RootfsManifestState, type RootfsPublicationArtifacts, type RootfsPublicationResult, type RootfsPublishExecutionResult, type RootfsPublishOptions, type RootfsStoreMessageResponse, type RootfsStoreMessageStatus, type RootfsToolchainAvailability, buildRootfs, contractShellEnv, createDockerRootfsBuildPipeline, createDockerRootfsExecutionPlan, createHostRootfsBuildPipeline, createHostRootfsExecutionPlan, createRootfsBuildPipeline, createRootfsBuildPlan, createRootfsManifest, createRootfsPublicationResult, createRootfsScriptCommand, deriveRootfsVersion, extractRootfsCid, extractRootfsSourceSizeBytes, finalizeRootfsBuildPipeline, parseIpfsAddResponse, parseRootfsContract, parseStoreMessageResponse, parseStoreMessageStatus, publicationArtifacts, publishRootfs, readRootfsContractFile, referenceProfileContractPath, referenceProfileRoot, referenceProfileRootfsDir, resolveRootfsManifestOutputPaths, rootfsBuildShellEnv, rootfsSourceSizeBytesFromIpfsAddResponse, selectRootfsExecutionPlan, serializeRootfsManifest, summarizeStoreMessageFailure, validateRootfsContract, validateRootfsManifest };
273
+ export { ITEM_HASH_RE, type RootfsBuildDriver, type RootfsBuildOptions, type RootfsBuildPipeline, type RootfsBuildPlan, type RootfsCommandPlan, type RootfsCommandRunner, type RootfsContract, type RootfsContractManifest, type RootfsContractPort, type RootfsContractRootfs, type RootfsContractServices, type RootfsContractSource, type RootfsContractState, type RootfsExecutedCommand, type RootfsExecutionPlan, type RootfsExecutionPlanOptions, type RootfsExecutionResult, type RootfsExecutorDependencies, type RootfsFileReader, type RootfsFinalizeOptions, type RootfsFinalizeResult, type RootfsIpfsAddEntry, type RootfsManifest, type RootfsManifestOptions, type RootfsManifestOutputPaths, type RootfsManifestState, type RootfsPublicationArtifacts, type RootfsPublicationResult, type RootfsPublishExecutionResult, type RootfsPublishOptions, type RootfsStoreMessageResponse, type RootfsStoreMessageStatus, type RootfsToolchainAvailability, buildRootfs, contractShellEnv, createDockerRootfsBuildPipeline, createDockerRootfsExecutionPlan, createHostRootfsBuildPipeline, createHostRootfsExecutionPlan, createRootfsBuildPipeline, createRootfsBuildPlan, createRootfsManifest, createRootfsPublicationResult, createRootfsScriptCommand, deriveRootfsVersion, extractRootfsCid, extractRootfsSourceSizeBytes, finalizeRootfsBuildPipeline, parseIpfsAddResponse, parseRootfsContract, parseStoreMessageResponse, parseStoreMessageStatus, profileAssetCommands, publicationArtifacts, publishRootfs, readRootfsContractFile, referenceProfileContractPath, referenceProfileRoot, referenceProfileRootfsDir, resolveRootfsManifestOutputPaths, rootfsBuildShellEnv, rootfsSourceSizeBytesFromIpfsAddResponse, selectRootfsExecutionPlan, serializeRootfsManifest, summarizeStoreMessageFailure, validateRootfsContract, validateRootfsManifest };
package/index.js CHANGED
@@ -385,7 +385,9 @@ function serializeRootfsManifest(manifest) {
385
385
  }
386
386
 
387
387
  // src/execution-plan.ts
388
+ import { existsSync as existsSync2 } from "fs";
388
389
  import path2 from "path";
390
+ import { fileURLToPath as fileURLToPath2 } from "url";
389
391
  function ensurePathWithin(parent, child, label) {
390
392
  const relative = path2.relative(parent, child);
391
393
  if (relative.startsWith("..") || path2.isAbsolute(relative)) {
@@ -411,12 +413,25 @@ function resolveReferenceRootfsDir(contract, override) {
411
413
  }
412
414
  return referenceProfileRootfsDir(contract.id);
413
415
  }
416
+ function profileAssetCommands(contract, referenceRootfsDir) {
417
+ if (contract.rootfs.profile !== "orbitdb-relay") return [];
418
+ const bundler = fileURLToPath2(new URL("../../attest-relay/scripts/bundle.mjs", import.meta.url));
419
+ if (!existsSync2(bundler)) return [];
420
+ return [
421
+ {
422
+ command: process.execPath,
423
+ args: [bundler, path2.join(referenceRootfsDir, "attest-relay.cjs")],
424
+ workdir: path2.dirname(bundler)
425
+ }
426
+ ];
427
+ }
414
428
  function createHostRootfsExecutionPlan(plan, options = {}) {
415
429
  const referenceRootfsDir = resolveReferenceRootfsDir(plan.contract, options.referenceRootfsDir);
416
430
  return {
417
431
  mode: "host",
418
432
  reason: "Using host virt-customize/qemu-img toolchain.",
419
433
  referenceRootfsDir,
434
+ assetCommands: profileAssetCommands(plan.contract, referenceRootfsDir),
420
435
  runCommand: {
421
436
  command: "/bin/bash",
422
437
  args: [path2.join(referenceRootfsDir, "build-rootfs-image.sh")],
@@ -462,6 +477,7 @@ function createDockerRootfsExecutionPlan(plan, options = {}) {
462
477
  mode: "docker",
463
478
  reason: "Using Dockerized Debian/libguestfs builder.",
464
479
  referenceRootfsDir,
480
+ assetCommands: profileAssetCommands(plan.contract, referenceRootfsDir),
465
481
  prepareCommand: {
466
482
  command: "docker",
467
483
  args: [
@@ -662,6 +678,11 @@ function createRootfsScriptCommand(buildPlan, referenceRootfsDir) {
662
678
  async function buildRootfs(buildPlan, deps, availability, options = {}) {
663
679
  const pipeline = createRootfsBuildPipeline(buildPlan, availability, options);
664
680
  const executedCommands = [];
681
+ for (const asset of pipeline.executionPlan.assetCommands ?? []) {
682
+ const assetCommand = { command: asset.command, args: asset.args, workdir: asset.workdir, env: asset.env };
683
+ await deps.run(assetCommand);
684
+ executedCommands.push(assetCommand);
685
+ }
665
686
  if (pipeline.executionPlan.prepareCommand) {
666
687
  const prepareCommand = {
667
688
  command: pipeline.executionPlan.prepareCommand.command,
@@ -746,6 +767,7 @@ export {
746
767
  parseRootfsContract,
747
768
  parseStoreMessageResponse,
748
769
  parseStoreMessageStatus,
770
+ profileAssetCommands,
749
771
  publicationArtifacts,
750
772
  publishRootfs,
751
773
  readRootfsContractFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/rootfs",
3
- "version": "0.9.9",
3
+ "version": "0.11.0",
4
4
  "description": "Shared rootfs contract parsing, reference profile assets, and build helpers.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,37 @@
1
+ # Confidential relay rootfs
2
+
3
+ A confidential (AMD SEV-SNP) instance on Aleph does not boot a rootfs the way a
4
+ normal instance does. Aleph's measured init waits for the owner to attest the
5
+ guest and inject the LUKS passphrase, unlocks the rootfs, **chroots** into it and
6
+ runs its `/sbin/init`. That init is not PID 1, nothing provisions the image from
7
+ outside (no cloud-init), and the encrypted image is uploaded in full.
8
+
9
+ `make-confidential-rootfs.sh` turns the image a relay profile build makes into
10
+ a plain ext4 that `aleph instance create --confidential --encrypt-rootfs` (or
11
+ `--image`, see below) can boot:
12
+
13
+ | Change | Why |
14
+ | --- | --- |
15
+ | `/sbin/init` → `confidential-init.sh` | a regular file that stays in the foreground: mounts `/run`, devpts and cgroup2, grows the filesystem (`EXT4_IOC_RESIZE_FS`, since `resize2fs` refuses in the chroot), makes SSH host keys on first boot, then runs `systemctl init` |
16
+ | `systemctl`, `journalctl` → [docker-systemctl-replacement](https://github.com/gdraheim/docker-systemctl-replacement) (pinned, checksummed) behind `confidential-systemctl.py` | starts the enabled units and keeps answering the `systemctl` / `journalctl` calls the profile scripts make, so the profile itself does not change; the wrapper runs `.timer` units (OnBootSec / OnActiveSec, then OnUnitActiveSec, with RandomizedDelaySec), which the replacement does not |
17
+ | cloud-init, networkd, resolved, timesyncd, getty, … off | the measured init already set up network and DNS |
18
+ | `Type=notify` units run as `Type=simple`; the init sets `net.ipv4.ip_unprivileged_port_start=0` | the replacement receives no sd_notify readiness and applies no `AmbientCapabilities`, so Caddy (WSS on 443) would not start (#168) |
19
+ | root's `authorized_keys` from the caller, plus any injected with the passphrase; host keys removed | an encrypted rootfs cannot be provisioned from the INSTANCE message; the widget sends the deployer's key (with its bootstrap token) as the `authorized_keys` secret over the attested channel, and the init adds its key lines before the units start; host keys exist only inside it |
20
+ | no kernel, modules, docs, locales, apt caches | the guest runs the runtime's measured kernel; the image should stay small |
21
+
22
+ ## Image size
23
+
24
+ `aleph instance create --encrypt-rootfs` uploads through the CCN, which takes at
25
+ most 1 GiB per file. Above ~900 MiB plain, encrypt the image yourself (LUKS2,
26
+ `--cipher aes-xts-plain64`, whole device, ext4 inside: the measured init
27
+ rejects any other cipher), add it through `ipfs.aleph.cloud`, reference it from
28
+ a STORE message and start it with `aleph instance create --confidential --image
29
+ <store hash>`. The **Aleph Confidential Relay Smoke** workflow does both.
30
+ orbitdb-relay is 1.7 GiB plain today; slimming it is #161.
31
+
32
+ ## Known gaps
33
+
34
+ - Timers are approximated: no OnCalendar, no Persistent catch-up across a
35
+ reboot (an encrypted instance re-waits for its passphrase after one anyway).
36
+ - `aleph-vm-deploy` takes `confidential: true` with an encrypted rootfs from
37
+ `.github/actions/aleph-confidential-rootfs`.
@@ -0,0 +1,52 @@
1
+ #!/bin/sh
2
+ # /sbin/init of a confidential (SEV-SNP) relay rootfs.
3
+ #
4
+ # Aleph's measured init unlocks the LUKS rootfs, chroots into it and runs this
5
+ # file. It is not PID 1, so systemd cannot run; docker-systemctl-replacement
6
+ # starts the enabled units instead and keeps answering the `systemctl` and
7
+ # `journalctl` calls the profile scripts make; confidential-systemctl.py
8
+ # (/usr/bin/systemctl) adds the .timer units it does not run.
9
+ # This script must stay in the foreground: when it exits, the VM powers off.
10
+ export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
11
+
12
+ mkdir -p /run /dev/pts /sys/fs/cgroup /var/log
13
+ mount -t tmpfs -o mode=755 tmpfs /run
14
+ mkdir -p /run/sshd /run/lock
15
+ mount -t devpts -o gid=5,mode=620,ptmxmode=666 devpts /dev/pts 2>/dev/null
16
+ mountpoint -q /sys/fs/cgroup || mount -t cgroup2 none /sys/fs/cgroup 2>/dev/null
17
+ [ -s /etc/hostname ] && hostname -F /etc/hostname 2>/dev/null
18
+ # Units that bind low ports rely on AmbientCapabilities=CAP_NET_BIND_SERVICE,
19
+ # which the systemctl replacement does not apply (relay-button#168).
20
+ echo 0 > /proc/sys/net/ipv4/ip_unprivileged_port_start 2>/dev/null
21
+
22
+ # The CRN grows the disk, not the filesystem inside the LUKS volume, and
23
+ # resize2fs refuses in the chroot (it does not see / as mounted), so ask the
24
+ # kernel to grow it online (EXT4_IOC_RESIZE_FS).
25
+ if [ -b /dev/mapper/cryptroot ]; then
26
+ bs=$(tune2fs -l /dev/mapper/cryptroot 2>/dev/null | sed -n 's/^Block size: *//p')
27
+ if [ -n "$bs" ]; then
28
+ blocks=$(( $(blockdev --getsize64 /dev/mapper/cryptroot) / bs ))
29
+ perl -e 'sysopen(my $f, "/", 0) or die "open: $!"; ioctl($f, 0x40086610, pack("Q", $ARGV[0])) or die "EXT4_IOC_RESIZE_FS: $!\n"' "$blocks" \
30
+ > /var/log/confidential-resize.log 2>&1
31
+ fi
32
+ fi
33
+
34
+ # Host keys are made on the first boot, inside the encrypted disk, so they
35
+ # never exist outside it.
36
+ ls /etc/ssh/ssh_host_*_key >/dev/null 2>&1 || ssh-keygen -A > /dev/null 2>&1
37
+
38
+ # Keys injected with the passphrase over the attested channel (the widget
39
+ # sends the deployer's key with its bootstrap token there, since nothing reads
40
+ # the INSTANCE message's authorized_keys here). Key lines only, no options.
41
+ if [ -s /tmp/secrets/authorized_keys ]; then
42
+ mkdir -p -m 700 /root/.ssh
43
+ touch /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys
44
+ grep -E '^(ssh-(rsa|ed25519)|ecdsa-sha2-nistp(256|384|521)|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com) [A-Za-z0-9+/]+={0,3}( [^[:cntrl:]]*)?$' /tmp/secrets/authorized_keys \
45
+ | while read -r line; do
46
+ grep -qxF "$line" /root/.ssh/authorized_keys || printf '%s\n' "$line" >> /root/.ssh/authorized_keys
47
+ done
48
+ fi
49
+
50
+ echo "confidential-init: $(uname -r), starting the enabled units" > /dev/console
51
+ /usr/bin/python3 /usr/bin/systemctl __start-enabled-timers
52
+ exec /usr/bin/python3 /usr/lib/confidential/systemctl3.py init
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/python3
2
+ """systemctl for a confidential rootfs: timers here, everything else in
3
+ docker-systemctl-replacement.
4
+
5
+ docker-systemctl-replacement does not run .timer units ("start not
6
+ implemented for unit type"), but the relay profiles start and enable timers in
7
+ their configure scripts, under `set -e`. This wrapper handles start, restart,
8
+ stop, enable, disable, is-active, is-enabled and status for .timer units: each
9
+ started timer is a small background loop that starts its service after
10
+ OnBootSec / OnActiveSec and then every OnUnitActiveSec, with
11
+ RandomizedDelaySec. All other calls go to the replacement unchanged, except
12
+ that --no-block is honoured: the replacement ignores it and would wait for a
13
+ oneshot unit to finish (the AutoTLS refresh waits for its certificate), so the
14
+ call is run in the background and returns at once, as it does under systemd.
15
+ """
16
+
17
+ import os
18
+ import random
19
+ import re
20
+ import signal
21
+ import subprocess
22
+ import sys
23
+ import time
24
+
25
+ REAL = "/usr/lib/confidential/systemctl3.py"
26
+ UNIT_DIRS = ["/etc/systemd/system", "/run/systemd/system", "/lib/systemd/system", "/usr/lib/systemd/system"]
27
+ WANTS = "/etc/systemd/system/timers.target.wants"
28
+ RUN_DIR = "/run/confidential-timers"
29
+ UNITS = {"us": 1e-6, "ms": 1e-3, "s": 1, "sec": 1, "second": 1, "seconds": 1, "m": 60, "min": 60,
30
+ "minute": 60, "minutes": 60, "h": 3600, "hr": 3600, "hour": 3600, "hours": 3600,
31
+ "d": 86400, "day": 86400, "days": 86400, "w": 604800, "week": 604800, "weeks": 604800}
32
+
33
+
34
+ def seconds(value):
35
+ """systemd time span: "2min", "6h", "1h 30min", "90" (seconds)."""
36
+ value = (value or "").strip()
37
+ if not value:
38
+ return None
39
+ if re.fullmatch(r"\d+(\.\d+)?", value):
40
+ return float(value)
41
+ total = 0.0
42
+ for number, unit in re.findall(r"(\d+(?:\.\d+)?)\s*([a-z]+)", value):
43
+ total += float(number) * UNITS.get(unit, 0)
44
+ return total
45
+
46
+
47
+ def unit_file(name):
48
+ for directory in UNIT_DIRS:
49
+ path = os.path.join(directory, name)
50
+ if os.path.exists(path):
51
+ return path
52
+ return None
53
+
54
+
55
+ def timer_settings(name):
56
+ path = unit_file(name)
57
+ if not path:
58
+ return None
59
+ settings, section = {}, None
60
+ with open(path) as handle:
61
+ for line in handle:
62
+ line = line.strip()
63
+ if line.startswith("["):
64
+ section = line.strip("[]")
65
+ elif section == "Timer" and "=" in line and not line.startswith("#"):
66
+ key, value = line.split("=", 1)
67
+ settings[key.strip()] = value.strip()
68
+ return settings
69
+
70
+
71
+ def pid_file(name):
72
+ return os.path.join(RUN_DIR, name + ".pid")
73
+
74
+
75
+ def running_pid(name):
76
+ try:
77
+ pid = int(open(pid_file(name)).read())
78
+ os.kill(pid, 0)
79
+ return pid
80
+ except (OSError, ValueError):
81
+ return None
82
+
83
+
84
+ def run_loop(name):
85
+ """The timer itself; runs detached until stopped."""
86
+ settings = timer_settings(name) or {}
87
+ service = settings.get("Unit") or name[: -len(".timer")] + ".service"
88
+ first = seconds(settings.get("OnActiveSec")) or seconds(settings.get("OnBootSec")) or 0
89
+ period = seconds(settings.get("OnUnitActiveSec")) or seconds(settings.get("OnUnitInactiveSec"))
90
+ jitter = seconds(settings.get("RandomizedDelaySec")) or 0
91
+ time.sleep(first + random.uniform(0, jitter))
92
+ while True:
93
+ subprocess.call([sys.executable, REAL, "start", service])
94
+ if not period:
95
+ return
96
+ time.sleep(period + random.uniform(0, jitter))
97
+
98
+
99
+ def start(name):
100
+ if not timer_settings(name):
101
+ print(f"Unit {name} not found.", file=sys.stderr)
102
+ return 5
103
+ if running_pid(name):
104
+ return 0
105
+ os.makedirs(RUN_DIR, exist_ok=True)
106
+ child = subprocess.Popen([sys.executable, os.path.abspath(__file__), "__run-timer", name],
107
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
108
+ start_new_session=True)
109
+ with open(pid_file(name), "w") as handle:
110
+ handle.write(str(child.pid))
111
+ return 0
112
+
113
+
114
+ def stop(name):
115
+ pid = running_pid(name)
116
+ if pid:
117
+ os.kill(pid, signal.SIGTERM)
118
+ try:
119
+ os.remove(pid_file(name))
120
+ except OSError:
121
+ pass
122
+ return 0
123
+
124
+
125
+ def enable(name):
126
+ path = unit_file(name)
127
+ if not path:
128
+ return 5
129
+ os.makedirs(WANTS, exist_ok=True)
130
+ link = os.path.join(WANTS, name)
131
+ if not os.path.lexists(link):
132
+ os.symlink(path, link)
133
+ return 0
134
+
135
+
136
+ def disable(name):
137
+ link = os.path.join(WANTS, name)
138
+ if os.path.lexists(link):
139
+ os.remove(link)
140
+ return 0
141
+
142
+
143
+ def handle_timers(command, names, now):
144
+ code = 0
145
+ for name in names:
146
+ if command == "start":
147
+ code |= start(name)
148
+ elif command == "restart":
149
+ stop(name)
150
+ code |= start(name)
151
+ elif command == "stop":
152
+ code |= stop(name)
153
+ elif command == "enable":
154
+ code |= enable(name)
155
+ if now:
156
+ code |= start(name)
157
+ elif command == "disable":
158
+ code |= disable(name)
159
+ if now:
160
+ stop(name)
161
+ elif command == "is-active":
162
+ active = running_pid(name) is not None
163
+ print("active" if active else "inactive")
164
+ code |= 0 if active else 3
165
+ elif command == "is-enabled":
166
+ enabled = os.path.lexists(os.path.join(WANTS, name))
167
+ print("enabled" if enabled else "disabled")
168
+ code |= 0 if enabled else 1
169
+ elif command == "status":
170
+ pid = running_pid(name)
171
+ print(f"{name} - {'active (waiting), pid ' + str(pid) if pid else 'inactive (dead)'}")
172
+ code |= 0 if pid else 3
173
+ return code
174
+
175
+
176
+ def main(argv):
177
+ if len(argv) >= 2 and argv[0] == "__run-timer":
178
+ run_loop(argv[1])
179
+ return 0
180
+ if len(argv) >= 1 and argv[0] == "__start-enabled-timers":
181
+ for name in sorted(os.listdir(WANTS)) if os.path.isdir(WANTS) else []:
182
+ if name.endswith(".timer"):
183
+ start(name)
184
+ return 0
185
+ words = [arg for arg in argv if not arg.startswith("-")]
186
+ command = words[0] if words else ""
187
+ timers = [word for word in words[1:] if word.endswith(".timer")]
188
+ others = [word for word in words[1:] if not word.endswith(".timer")]
189
+ if "--no-block" in argv:
190
+ rest = [arg for arg in argv if arg != "--no-block"]
191
+ subprocess.Popen([sys.executable, os.path.abspath(__file__)] + rest,
192
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
193
+ start_new_session=True)
194
+ return 0
195
+ if not timers:
196
+ os.execv(sys.executable, [sys.executable, REAL] + argv)
197
+ code = handle_timers(command, timers, "--now" in argv)
198
+ if others:
199
+ flags = [arg for arg in argv if arg.startswith("-")]
200
+ code |= subprocess.call([sys.executable, REAL] + flags + [command] + others)
201
+ return code
202
+
203
+
204
+ if __name__ == "__main__":
205
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env bash
2
+ # Turn a relay profile image (the qcow2 or raw disk the profile build makes)
3
+ # into a plain ext4 rootfs that `aleph instance create --confidential
4
+ # --encrypt-rootfs` can encrypt and boot on an SEV-SNP node.
5
+ #
6
+ # make-confidential-rootfs.sh <image> <out.ext4> <authorized_keys>
7
+ #
8
+ # What changes in the image, and why:
9
+ # - /sbin/init becomes confidential-init.sh (a regular file, as the measured
10
+ # init requires): systemd cannot be PID 1 in the chroot.
11
+ # - /usr/bin/systemctl and /usr/bin/journalctl become docker-systemctl-
12
+ # replacement, pinned below, behind confidential-systemctl.py, which runs
13
+ # .timer units; the profile's units and scripts keep working.
14
+ # - cloud-init and the systemd services that manage what the measured init
15
+ # already did (network, DNS, getty, timesyncd) are no longer started.
16
+ # - Type=notify units run as Type=simple, and the init lets any user bind
17
+ # ports below 1024 (the replacement has neither sd_notify nor capabilities).
18
+ # - root's authorized_keys come from the caller: nothing provisions an
19
+ # encrypted rootfs from outside. Host keys are removed and made on first boot.
20
+ # - Kernel, modules, docs and caches go: the guest runs the runtime's measured
21
+ # kernel, and the encrypted image must stay below the CCN's 1 GiB upload limit.
22
+ #
23
+ # Needs root (qemu-nbd, mount), qemu-utils, rsync, e2fsprogs and curl.
24
+ set -euo pipefail
25
+
26
+ SYSTEMCTL_REPLACEMENT_COMMIT=8bd65bec50650fcc740e2d55c32162ba7bafdfc8 # v1.7.1097
27
+ SYSTEMCTL3_SHA256=5f5a47f321c7a8881dfd01f774106dc4012d664bee238eada86a7112674fea93
28
+ JOURNALCTL3_SHA256=16a52fe484d7c0fca464c82a56de58980440362942482c0d1fbc424e69f8f833
29
+ MAX_PLAIN_MIB="${MAX_PLAIN_MIB:-900}"
30
+
31
+ image="${1:?usage: make-confidential-rootfs.sh <image> <out.ext4> <authorized_keys>}"
32
+ out="${2:?missing output path}"
33
+ keys="${3:?missing authorized_keys file}"
34
+ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
35
+ [ "$(id -u)" = 0 ] || { echo "run as root" >&2; exit 1; }
36
+ [ -s "$keys" ] || { echo "authorized_keys file is empty: $keys" >&2; exit 1; }
37
+
38
+ work="$(mktemp -d)"
39
+ mnt="$work/mnt"
40
+ root="$work/root"
41
+ nbd=""
42
+ cleanup() {
43
+ mountpoint -q "$mnt" && umount "$mnt"
44
+ [ -n "$nbd" ] && qemu-nbd -d "$nbd" >/dev/null 2>&1
45
+ rm -rf "$work"
46
+ }
47
+ trap cleanup EXIT
48
+ mkdir -p "$mnt" "$root"
49
+
50
+ # 1. Mount the image's root filesystem (the largest ext4 partition).
51
+ modprobe nbd max_part=16
52
+ nbd=/dev/nbd0
53
+ qemu-nbd -r -c "$nbd" "$image"
54
+ udevadm settle
55
+ part="$(lsblk -lnbo NAME,FSTYPE,SIZE "$nbd" | awk '$2 == "ext4" { print $3, $1 }' | sort -n | tail -1 | cut -d' ' -f2)"
56
+ [ -n "$part" ] || { echo "no ext4 partition in $image" >&2; exit 1; }
57
+ mount -o ro "/dev/$part" "$mnt"
58
+
59
+ # 2. Copy it, leaving out what a confidential guest never uses.
60
+ rsync -aHAX --numeric-ids \
61
+ --exclude=/boot/ --exclude=/usr/lib/modules/ --exclude=/lib/modules/ \
62
+ --exclude=/usr/share/doc/ --exclude=/usr/share/man/ --exclude=/usr/share/info/ \
63
+ --exclude=/usr/share/locale/ --exclude=/var/cache/apt/ --exclude=/var/lib/apt/lists/ \
64
+ --exclude=/var/log/ --exclude=/var/lib/cloud/ --exclude=/tmp/ \
65
+ "$mnt/" "$root/"
66
+ mkdir -p "$root"/{boot,var/log,tmp,var/cache/apt,var/lib/apt/lists}
67
+ chmod 1777 "$root/tmp"
68
+ umount "$mnt"
69
+
70
+ # 3. The systemd replacement.
71
+ fetch() {
72
+ curl -fsSL -o "$2" "https://raw.githubusercontent.com/gdraheim/docker-systemctl-replacement/${SYSTEMCTL_REPLACEMENT_COMMIT}/files/docker/$1"
73
+ echo "$3 $2" | sha256sum -c - >/dev/null
74
+ }
75
+ fetch systemctl3.py "$work/systemctl3.py" "$SYSTEMCTL3_SHA256"
76
+ fetch journalctl3.py "$work/journalctl3.py" "$JOURNALCTL3_SHA256"
77
+ for bin in systemctl journalctl; do
78
+ for dir in usr/bin bin; do
79
+ [ -e "$root/$dir/$bin" ] && [ ! -L "$root/$dir/$bin" ] && mv "$root/$dir/$bin" "$root/$dir/$bin.systemd"
80
+ done
81
+ done
82
+ install -d "$root/usr/lib/confidential"
83
+ install -m 755 "$work/systemctl3.py" "$root/usr/lib/confidential/systemctl3.py"
84
+ # The replacement does not run .timer units; this wrapper does, and passes
85
+ # everything else on.
86
+ install -m 755 "$here/confidential-systemctl.py" "$root/usr/bin/systemctl"
87
+ install -m 755 "$work/journalctl3.py" "$root/usr/bin/journalctl"
88
+ # journalctl3.py calls systemctl3.py from its own directory.
89
+ ln -sf /usr/lib/confidential/systemctl3.py "$root/usr/bin/systemctl3.py"
90
+ [ -x "$root/usr/bin/python3" ] || { echo "the image has no python3, which the systemctl replacement needs" >&2; exit 1; }
91
+
92
+ # 4. Stop starting what the measured init already did, or what has no place here.
93
+ wants="$root/etc/systemd/system"
94
+ find "$wants" -path '*.wants/*' \( \
95
+ -name 'cloud-*' -o -name 'systemd-networkd*' -o -name 'systemd-resolved*' \
96
+ -o -name 'systemd-timesyncd*' -o -name 'networking.service' -o -name 'getty@*' \
97
+ -o -name 'serial-getty@*' -o -name 'e2scrub*' -o -name 'fstrim*' -o -name 'apt-daily*' \
98
+ -o -name 'man-db*' -o -name 'unattended-upgrades*' -o -name 'systemd-pstore*' \
99
+ -o -name 'qemu-guest-agent*' -o -name 'remote-fs.target' \) -delete
100
+ # cloud-init is also pulled in from units under /lib; nothing may start it.
101
+ find "$root/etc/systemd" "$root/lib/systemd/system" "$root/usr/lib/systemd/system" \
102
+ -name 'cloud-*' \( -type l -o -type f \) -delete 2>/dev/null || true
103
+ rm -f "$root/usr/bin/cloud-init" "$root/usr/bin/cloud-id"
104
+ [ -s "$root/etc/hostname" ] || echo relay > "$root/etc/hostname"
105
+
106
+ # 4b. Units the replacement cannot run as written (relay-button#168): it does
107
+ # not receive sd_notify readiness, so Type=notify becomes Type=simple (a
108
+ # reverse proxy such as Caddy needs no readiness handshake here); and it does
109
+ # not apply AmbientCapabilities, so low ports are opened to every user by the
110
+ # init (net.ipv4.ip_unprivileged_port_start=0) instead.
111
+ for dir in "$root/etc/systemd/system" "$root/lib/systemd/system" "$root/usr/lib/systemd/system"; do
112
+ [ -d "$dir" ] || continue
113
+ # grep exits 1 when a directory has none; under pipefail that would end the script.
114
+ { grep -rlE '^Type=notify(-reload)?$' "$dir" --include='*.service' 2>/dev/null || true; } | while read -r unit; do
115
+ sed -i -E 's/^Type=notify(-reload)?$/Type=simple/; /^NotifyAccess=/d' "$unit"
116
+ echo "Type=notify -> simple: ${unit#$root}"
117
+ done
118
+ done
119
+
120
+ # 5. Init, DNS placeholder, SSH.
121
+ rm -f "$root/sbin/init"
122
+ install -m 755 "$here/confidential-init.sh" "$root/sbin/init"
123
+ rm -f "$root/etc/resolv.conf"
124
+ touch "$root/etc/resolv.conf"
125
+ rm -f "$root"/etc/ssh/ssh_host_*
126
+ install -d -m 700 "$root/root/.ssh"
127
+ install -m 600 "$keys" "$root/root/.ssh/authorized_keys"
128
+ ln -sf /lib/systemd/system/ssh.service "$wants/multi-user.target.wants/ssh.service" 2>/dev/null || true
129
+
130
+ # 6. A tight ext4: the CRN grows the disk, the init grows the filesystem.
131
+ size_mib=$(( $(du -sm "$root" | cut -f1) * 110 / 100 + 64 ))
132
+ echo "rootfs content: $(du -sh "$root" | cut -f1), plain image ${size_mib} MiB"
133
+ [ "$size_mib" -le "$MAX_PLAIN_MIB" ] || {
134
+ echo "largest directories:" >&2
135
+ du -xm --max-depth=4 "$root" 2>/dev/null | sort -n | tail -30 | sed "s|$root||" >&2
136
+ echo "the plain image (${size_mib} MiB) is above ${MAX_PLAIN_MIB} MiB and would exceed the 1 GiB upload limit once encrypted" >&2
137
+ exit 1
138
+ }
139
+ rm -f "$out"
140
+ truncate -s "${size_mib}M" "$out"
141
+ mkfs.ext4 -q -b 4096 -T default -d "$root" "$out"
142
+ echo "wrote $out"
@@ -71,6 +71,6 @@
71
71
  ],
72
72
  "manifest": {
73
73
  "copyTarget": "public/rootfs-manifest.json",
74
- "notes": "The OrbitDB relay image prebakes Node.js, production dependencies, and Caddy into the qcow2, delays first relay start until Aleph host port mappings are known, exposes a temporary setup endpoint on port 80, and uses Caddy on port 443 to front both HTTPS helper routes and secure libp2p WSS transport for the configured proxy hostname."
74
+ "notes": "The OrbitDB relay image prebakes Node.js, production dependencies, and Caddy into the qcow2, delays first relay start until Aleph host port mappings are known, exposes a temporary setup endpoint on port 80, and uses Caddy on port 443 to front both HTTPS helper routes and secure libp2p WSS transport for the configured proxy hostname, and serves the attest relay (wss://<proxy hostname>/attest) that browsers use to attest and unlock confidential instances."
75
75
  }
76
76
  }
@@ -0,0 +1,27 @@
1
+ [Unit]
2
+ Description=Attest relay: WebSocket to an SEV-SNP guest's attestation port, behind Caddy at /attest
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ Type=simple
8
+ # Loopback only: Caddy terminates TLS and proxies wss://<host>/attest here.
9
+ Environment=ATTEST_RELAY_HOST=127.0.0.1
10
+ Environment=ATTEST_RELAY_PORT=8787
11
+ Environment=ATTEST_RELAY_TRUST_PROXY=1
12
+ # Optional overrides (ATTEST_RELAY_ORIGINS, limits) for the operator.
13
+ EnvironmentFile=-/etc/default/attest-relay
14
+ ExecStart=/usr/bin/node /usr/local/lib/attest-relay/attest-relay.cjs
15
+ User=nobody
16
+ Group=nogroup
17
+ NoNewPrivileges=yes
18
+ ProtectSystem=strict
19
+ ProtectHome=yes
20
+ PrivateTmp=yes
21
+ Restart=on-failure
22
+ RestartSec=5
23
+ StandardOutput=journal
24
+ StandardError=journal
25
+
26
+ [Install]
27
+ WantedBy=multi-user.target
@@ -228,6 +228,29 @@ case "${ROOTFS_PROFILE}" in
228
228
  LICENSE \
229
229
  .env.example
230
230
 
231
+ # The attest relay (packages/attest-relay) as one file, so browsers reach
232
+ # a confidential guest's RA-TLS port through wss://<relay host>/attest.
233
+ # The rootfs executor bundles it into this directory before the build
234
+ # (a Docker build sees only this directory); a host build in the
235
+ # workspace can bundle it here.
236
+ ATTEST_RELAY_BUNDLE="${ATTEST_RELAY_BUNDLE:-${SCRIPT_DIR}/attest-relay.cjs}"
237
+ attest_relay_bundler="${SCRIPT_DIR}/../../../../attest-relay/scripts/bundle.mjs"
238
+ if [ ! -s "${ATTEST_RELAY_BUNDLE}" ] && [ -f "${attest_relay_bundler}" ]; then
239
+ node "${attest_relay_bundler}" "${ATTEST_RELAY_BUNDLE}" || true
240
+ fi
241
+ attest_relay_args=()
242
+ if [ -s "${ATTEST_RELAY_BUNDLE}" ]; then
243
+ attest_relay_args=(
244
+ --mkdir /usr/local/lib/attest-relay
245
+ --copy-in "${ATTEST_RELAY_BUNDLE}:/usr/local/lib/attest-relay"
246
+ --copy-in "${SCRIPT_DIR}/attest-relay.service:/etc/systemd/system"
247
+ --run-command "cd /usr/local/lib/attest-relay && [ -f attest-relay.cjs ] || mv '$(basename "${ATTEST_RELAY_BUNDLE}")' attest-relay.cjs"
248
+ --run-command "systemctl enable attest-relay.service"
249
+ )
250
+ else
251
+ echo "WARNING: no attest relay bundle at ${ATTEST_RELAY_BUNDLE}; this image will not serve /attest." >&2
252
+ fi
253
+
231
254
  orbitdb_customize_args=(
232
255
  -a "${IMAGE}"
233
256
  --mkdir /opt/orbitdb-relay
@@ -262,6 +285,7 @@ case "${ROOTFS_PROFILE}" in
262
285
  --run-command "systemctl enable orbitdb-relay-bootstrap.service"
263
286
  --run-command "systemctl enable orbitdb-relay-autotls-refresh.timer"
264
287
  --run-command "systemctl enable orbitdb-relay.service"
288
+ "${attest_relay_args[@]}"
265
289
  --run-command "rm -f /opt/$(basename "${APP_TAR}")"
266
290
  )
267
291
 
@@ -13,6 +13,7 @@ CADDYFILE="${CADDYFILE:-/etc/caddy/Caddyfile}"
13
13
  CADDY_UPSTREAM_WSS_PORT="${CADDY_UPSTREAM_WSS_PORT:-9092}"
14
14
  CADDY_UPSTREAM_HOST="${CADDY_UPSTREAM_HOST:-127.0.0.1}"
15
15
  CADDY_UPSTREAM_METRICS_PORT="${CADDY_UPSTREAM_METRICS_PORT:-9090}"
16
+ CADDY_UPSTREAM_ATTEST_PORT="${CADDY_UPSTREAM_ATTEST_PORT:-8787}"
16
17
  # Internal libp2p listen ports. On the Aleph VM, IPv4 is NAT/port-mapped (so it
17
18
  # is announced on the externally assigned host ports), but IPv6 is globally
18
19
  # routed with NO NAT — the VM is reachable directly on the real listen ports,
@@ -158,6 +159,12 @@ https://${hostname} {
158
159
  reverse_proxy 127.0.0.1:${CADDY_UPSTREAM_METRICS_PORT}
159
160
  }
160
161
 
162
+ # The attest relay (attest-relay.service): a browser's RA-TLS records to a
163
+ # confidential guest's attestation port, which it cannot dial itself.
164
+ handle /attest {
165
+ reverse_proxy 127.0.0.1:${CADDY_UPSTREAM_ATTEST_PORT}
166
+ }
167
+
161
168
  reverse_proxy ${CADDY_UPSTREAM_HOST}:${CADDY_UPSTREAM_WSS_PORT}
162
169
  }
163
170
  EOF