From f4537180d8a24f3a4e64bcd3c783dd43717fa91d Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sat, 4 Apr 2026 22:30:33 -0700 Subject: [PATCH] feat: switch nix usb builds to raw images --- fedac/native/Dockerfile.flash-helper | 3 +- fedac/native/ac-os | 186 +++++++--------- fedac/native/scripts/media-layout.sh | 119 ++++++++++ fedac/native/scripts/nixos-image-helper.sh | 209 ++++++++++++++++++ fedac/native/scripts/upload-release.sh | 46 ++-- fedac/nixos/configuration.nix | 14 ++ fedac/nixos/flake.nix | 50 +++-- fedac/nixos/modules/image.nix | 27 +++ fedac/nixos/modules/kiosk.nix | 22 ++ lith/server.mjs | 10 +- oven-edge/worker.mjs | 137 +++++++----- oven/native-builder.mjs | 50 +++-- oven/server.mjs | 170 +++++--------- system/netlify/edge-functions/os-image.js | 13 +- .../edge-functions/os-release-upload.js | 10 +- system/public/aesthetic.computer/disks/os.mjs | 57 ++--- 16 files changed, 743 insertions(+), 380 deletions(-) create mode 100644 fedac/native/scripts/nixos-image-helper.sh create mode 100644 fedac/nixos/modules/image.nix diff --git a/fedac/native/Dockerfile.flash-helper b/fedac/native/Dockerfile.flash-helper index 3b71c439bb..1398bfa359 100644 --- a/fedac/native/Dockerfile.flash-helper +++ b/fedac/native/Dockerfile.flash-helper @@ -7,9 +7,10 @@ RUN dnf install -y --setopt=install_weak_deps=False \ COPY fedac/native/scripts/media-layout.sh /usr/local/lib/ac-media-layout.sh COPY fedac/native/scripts/flash-helper-runner.sh /usr/local/bin/ac-os-flash-helper +COPY fedac/native/scripts/nixos-image-helper.sh /usr/local/bin/ac-os-nixos-image-helper COPY fedac/native/boot/systemd-bootx64.efi /usr/local/lib/systemd-bootx64.efi COPY fedac/native/bootloader/splash.efi /usr/local/lib/splash.efi -RUN chmod +x /usr/local/bin/ac-os-flash-helper +RUN chmod +x /usr/local/bin/ac-os-flash-helper /usr/local/bin/ac-os-nixos-image-helper ENTRYPOINT ["/usr/local/bin/ac-os-flash-helper"] diff --git a/fedac/native/ac-os b/fedac/native/ac-os index 2a68ac46db..c3738ff8ae 100755 --- a/fedac/native/ac-os +++ b/fedac/native/ac-os @@ -16,7 +16,7 @@ VMLINUZ="${BUILD_DIR}/vmlinuz" INITRAMFS_ROOT="${BUILD_DIR}/initramfs-root" AC_MEDIA_NATIVE_DIR="${SCRIPT_DIR}" MEDIA_LAYOUT_LIB="${SCRIPT_DIR}/scripts/media-layout.sh" -MEDIA_HELPER_IMAGE="${AC_MEDIA_HELPER_IMAGE:-ac-os-media-helper:latest}" +MEDIA_HELPER_IMAGE="${AC_MEDIA_HELPER_IMAGE:-ac-os-media-helper:img-v1}" MEDIA_HELPER_DOCKERFILE="${SCRIPT_DIR}/Dockerfile.flash-helper" CMD="${1:-build}" @@ -854,7 +854,7 @@ write_usb_config_file() { USB_CONFIG='{"handle":"unknown"}' fi - printf '%s' "${USB_CONFIG}" > "${CONFIG_FILE}" + ac_media_write_legacy_config "${CONFIG_FILE}" "${USB_CONFIG}" log "USB config: $(ac_media_summarize_config_file "${CONFIG_FILE}")" } @@ -864,7 +864,7 @@ nixos_data_partition_for() { local PART DATA_LABEL="$(ac_media_nixos_data_label)" - for PART in "${USB_DEV}3" "${USB_DEV}p3"; do + for PART in "${USB_DEV}"? "${USB_DEV}"p? "${USB_DEV}"?? "${USB_DEV}"p??; do [ -b "${PART}" ] || continue if [ "$(sudo blkid -o value -s LABEL "${PART}" 2>/dev/null || true)" = "${DATA_LABEL}" ]; then printf '%s\n' "${PART}" @@ -917,16 +917,25 @@ write_nixos_usb_config() { log "Wrote NixOS config to ${DATA_PART}" } +prepare_nixos_image() { + local IMAGE_PATH="$1" + local CONFIG_FILE="$2" + local WORK_DIR + + ensure_media_helper_image + WORK_DIR="$(dirname "${IMAGE_PATH}")" + sudo docker run --rm --privileged \ + -v "${WORK_DIR}:/work" \ + --entrypoint /bin/bash "${MEDIA_HELPER_IMAGE}" \ + -lc "exec /usr/local/bin/ac-os-nixos-image-helper '/work/$(basename "${IMAGE_PATH}")' '/work/$(basename "${CONFIG_FILE}")'" +} + flash_nixos_image_to_usb() { local IMAGE_PATH="$1" local CONFIG_FILE="$2" local USB_DEV - ac_media_ensure_nixos_data_partition \ - "${IMAGE_PATH}" \ - "${CONFIG_FILE}" \ - "$(ac_media_nixos_data_size_mib)" - ac_media_customize_nixos_efi_boot "${IMAGE_PATH}" + prepare_nixos_image "${IMAGE_PATH}" "${CONFIG_FILE}" USB_DEV="$(find_usb_dev)" || { err "No USB device found"; exit 1; } log "Flashing ${IMAGE_PATH} to ${USB_DEV}..." @@ -1042,71 +1051,33 @@ load_vault_creds() { log "Credentials decrypted and cached for session" } -generate_template_iso() { - # Create a hybrid ISO from the staged chainloader-first boot tree. - # The config.json uses the existing 32KB identity block so oven can patch it. - local ISO="/tmp/ac-native.iso" - local STAGING="/tmp/ac-iso-staging" - local EFI_IMG="/tmp/ac-efi.img" - local CONFIG_TMP="/tmp/ac-identity-config.json" - - log "Generating template .iso..." >&2 - - # Clean staging area - rm -rf "${STAGING}" "${EFI_IMG}" "${ISO}" "${CONFIG_TMP}" - - # Create identity block (32KB, zero-padded) in ISO root. - # The block starts with a marker so the edge worker can verify alignment, - # followed by JSON config, zero-padded to exactly 32768 bytes. - local IDENTITY_SIZE - local IDENTITY_MARKER - IDENTITY_SIZE="$(ac_media_identity_size)" - IDENTITY_MARKER="$(ac_media_identity_marker)" - - ac_media_write_identity_config "${CONFIG_TMP}" - ac_media_stage_boot_tree "${STAGING}" "${VMLINUZ}" "${CONFIG_TMP}" - ac_media_create_fat_image "${STAGING}" "${EFI_IMG}" "AC_NATIVE" - ac_media_build_hybrid_iso "${STAGING}" "${EFI_IMG}" "${ISO}" "AC_NATIVE" - - rm -rf "${STAGING}" "${EFI_IMG}" "${CONFIG_TMP}" - - local ISO_BYTES=$(stat -c%s "${ISO}") - local ISO_MB=$(( ISO_BYTES / 1048576 )) - log "Template ISO: ${ISO} (${ISO_MB}MB)" >&2 - - # Find the identity block offset by searching for the marker in the ISO. - # Record it in a manifest so the edge worker can patch without scanning. - local OFFSET=$(python3 -c " -import sys -marker = b'${IDENTITY_MARKER}\n' -with open('${ISO}', 'rb') as f: - data = f.read() - idx = data.find(marker) - print(idx if idx >= 0 else -1) -" 2>/dev/null) - - if [ "${OFFSET}" = "-1" ] || [ -z "${OFFSET}" ]; then - log "WARNING: Identity block marker not found in ISO!" >&2 - else - log "Identity block at offset ${OFFSET} (${IDENTITY_SIZE} bytes)" >&2 - fi - - # Write manifest alongside the ISO +generate_template_image() { + local IMAGE="/tmp/ac-native.img" + local STAGING="/tmp/ac-img-staging" + local LEGACY_CONFIG="/tmp/ac-legacy-config.json" + local IDENTITY_FILE="/tmp/$(ac_media_identity_filename)" local MANIFEST="/tmp/ac-manifest.json" - cat > "${MANIFEST}" << MANIFEST_EOF -{ - "name": "${AC_BUILD_NAME:-unknown}", - "hash": "$(git rev-parse --short HEAD 2>/dev/null || echo unknown)", - "timestamp": "$(date -u '+%Y-%m-%dT%H:%M:%SZ')", - "identityBlockOffset": ${OFFSET:--1}, - "identityBlockSize": ${IDENTITY_SIZE}, - "identityMarker": "${IDENTITY_MARKER}", - "isoSize": ${ISO_BYTES} -} -MANIFEST_EOF + + log "Generating template .img..." >&2 + rm -rf "${STAGING}" "${IMAGE}" "${LEGACY_CONFIG}" "${IDENTITY_FILE}" "${MANIFEST}" + + ac_media_write_legacy_config "${LEGACY_CONFIG}" + ac_media_write_identity_config "${IDENTITY_FILE}" + ac_media_stage_boot_tree "${STAGING}" "${VMLINUZ}" "${LEGACY_CONFIG}" + cp "${IDENTITY_FILE}" "${STAGING}/$(ac_media_identity_filename)" + ac_media_create_efi_disk_image "${STAGING}" "${IMAGE}" "AC_NATIVE" + ac_media_generate_manifest "${IMAGE}" "${AC_BUILD_NAME:-unknown}" "${MANIFEST}" + + rm -rf "${STAGING}" "${LEGACY_CONFIG}" "${IDENTITY_FILE}" + + local IMAGE_BYTES + local IMAGE_MB + IMAGE_BYTES=$(stat -c%s "${IMAGE}") + IMAGE_MB=$(( IMAGE_BYTES / 1048576 )) + log "Template image: ${IMAGE} (${IMAGE_MB}MB)" >&2 log "Manifest: ${MANIFEST}" >&2 - echo "${ISO}" + echo "${IMAGE}" } upload_ota() { @@ -1217,29 +1188,28 @@ upload_ota() { fi fi - # Step 4: Generate and upload template .iso for personalized downloads - local TEMPLATE_ISO - TEMPLATE_ISO=$(generate_template_iso) && { - log "Uploading template .iso..." - # Get presigned URL for the .iso - local ISO_STEP - ISO_STEP=$(curl -sf -X POST "${UPLOAD_URL}" \ + # Step 4: Generate and upload template .img for personalized downloads + local TEMPLATE_IMG + TEMPLATE_IMG=$(generate_template_image) && { + log "Uploading template .img..." + local IMAGE_STEP + IMAGE_STEP=$(curl -sf -X POST "${UPLOAD_URL}" \ -H "Authorization: Bearer ${AC_TOKEN}" \ -H "X-Build-Name: ${AC_BUILD_NAME}" \ -H "X-Template-Upload: true" \ --max-time 30) || { log "Template presign failed (non-fatal)"; } - if [ -n "${ISO_STEP}" ]; then - local ISO_PUT_URL=$(echo "$ISO_STEP" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).iso_put_url||''))" 2>/dev/null) - if [ -n "${ISO_PUT_URL}" ] && [ -f "${TEMPLATE_ISO}" ]; then - log "Uploading template $(stat -c%s "${TEMPLATE_ISO}" | numfmt --to=iec) to S3..." - curl -sf -X PUT "${ISO_PUT_URL}" \ - -H "Content-Type: application/x-iso9660-image" \ + if [ -n "${IMAGE_STEP}" ]; then + local IMAGE_PUT_URL=$(echo "$IMAGE_STEP" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).image_put_url||''))" 2>/dev/null) + if [ -n "${IMAGE_PUT_URL}" ] && [ -f "${TEMPLATE_IMG}" ]; then + log "Uploading template $(stat -c%s "${TEMPLATE_IMG}" | numfmt --to=iec) to S3..." + curl -sf -X PUT "${IMAGE_PUT_URL}" \ + -H "Content-Type: application/octet-stream" \ -H "x-amz-acl: public-read" \ - -T "${TEMPLATE_ISO}" \ + -T "${TEMPLATE_IMG}" \ --max-time 300 && { - log "Template .iso uploaded" - # Upload manifest for edge worker ISO patching + log "Template .img uploaded" + # Upload manifest for edge worker image patching local MANIFEST="/tmp/ac-manifest.json" if [ -f "${MANIFEST}" ]; then local MANIFEST_STEP @@ -1264,10 +1234,10 @@ upload_ota() { curl -sf -X POST "https://oven.aesthetic.computer/os-cache-flush" \ --max-time 10 && log "Oven template cache flushed" \ || log "Cache flush failed (non-fatal)" - } || log "Template upload failed (non-fatal)" + } || log "Template upload failed (non-fatal)" fi fi - rm -f "${TEMPLATE_ISO}" + rm -f "${TEMPLATE_IMG}" } || log "Template generation failed (non-fatal)" # Record in MongoDB @@ -1564,15 +1534,15 @@ exec qemu-system-x86_64 \ log "Building + flashing NixOS image to USB..." cd "${NIXOS_DIR}" IMAGE_PATH=$(AC_NIX_NATIVE_SRC="${SCRIPT_DIR}" nix build .#usb-image --impure --print-out-paths --no-link) - ISO=$(find "${IMAGE_PATH}" -name '*.iso' -type f | head -1) - if [ -z "${ISO}" ]; then - err "No ISO found in nix build output" + IMG=$(find "${IMAGE_PATH}" -name '*.img' -type f | head -1) + if [ -z "${IMG}" ]; then + err "No image found in nix build output" exit 1 fi NIX_FLASH_DIR="$(mktemp -d /tmp/ac-nixos-flash.XXXXXX)" - NIX_FLASH_IMAGE="${NIX_FLASH_DIR}/ac-os-nixos.iso" + NIX_FLASH_IMAGE="${NIX_FLASH_DIR}/ac-os-nixos.img" NIX_FLASH_CONFIG="${NIX_FLASH_DIR}/config.json" - cp "${ISO}" "${NIX_FLASH_IMAGE}" + cp "${IMG}" "${NIX_FLASH_IMAGE}" write_usb_config_file "${NIX_FLASH_CONFIG}" flash_nixos_image_to_usb "${NIX_FLASH_IMAGE}" "${NIX_FLASH_CONFIG}" rm -rf "${NIX_FLASH_DIR}" @@ -1583,29 +1553,25 @@ exec qemu-system-x86_64 \ log "Building + uploading NixOS image..." cd "${NIXOS_DIR}" IMAGE_PATH=$(AC_NIX_NATIVE_SRC="${SCRIPT_DIR}" nix build .#usb-image --impure --print-out-paths --no-link) - ISO=$(find "${IMAGE_PATH}" -name '*.iso' -type f | head -1) - if [ -z "${ISO}" ]; then - err "No ISO found in nix build output" + IMG=$(find "${IMAGE_PATH}" -name '*.img' -type f | head -1) + if [ -z "${IMG}" ]; then + err "No image found in nix build output" exit 1 fi load_vault_creds NIX_UPLOAD_DIR="$(mktemp -d /tmp/ac-nixos-upload.XXXXXX)" - NIX_UPLOAD_IMAGE="${NIX_UPLOAD_DIR}/ac-os-nixos.iso" + NIX_UPLOAD_IMAGE="${NIX_UPLOAD_DIR}/ac-os-nixos.img" NIX_UPLOAD_CONFIG="${NIX_UPLOAD_DIR}/config.json" - cp "${ISO}" "${NIX_UPLOAD_IMAGE}" - ac_media_default_identity_json > "${NIX_UPLOAD_CONFIG}" - ac_media_ensure_nixos_data_partition \ - "${NIX_UPLOAD_IMAGE}" \ - "${NIX_UPLOAD_CONFIG}" \ - "$(ac_media_nixos_data_size_mib)" - ac_media_customize_nixos_efi_boot "${NIX_UPLOAD_IMAGE}" - OTA_CHANNEL=nix bash "${SCRIPT_DIR}/scripts/upload-release.sh" --iso "${NIX_UPLOAD_IMAGE}" + cp "${IMG}" "${NIX_UPLOAD_IMAGE}" + ac_media_write_legacy_config "${NIX_UPLOAD_CONFIG}" "$(ac_media_default_identity_json)" + prepare_nixos_image "${NIX_UPLOAD_IMAGE}" "${NIX_UPLOAD_CONFIG}" + OTA_CHANNEL=nix bash "${SCRIPT_DIR}/scripts/upload-release.sh" --image "${NIX_UPLOAD_IMAGE}" rm -rf "${NIX_UPLOAD_DIR}" ;; pull) require_login NIX_VERSION_URL="${CDN_BASE}/nix-native-notepat-latest.version" - NIX_ISO_URL="${CDN_BASE}/nix-native-notepat-latest.iso" + NIX_IMG_URL="${CDN_BASE}/nix-native-notepat-latest.img" NIX_HASH_URL="${CDN_BASE}/nix-native-notepat-latest.sha256" log "Fetching latest NixOS OTA version..." @@ -1616,14 +1582,14 @@ exec qemu-system-x86_64 \ PULL_DIR="/tmp/ac-os-nix-pull" mkdir -p "${PULL_DIR}" - PULLED_ISO="${PULL_DIR}/ac-os-nixos.iso" + PULLED_IMG="${PULL_DIR}/ac-os-nixos.img" log "Downloading NixOS image (~$(( IMAGE_SIZE / 1048576 ))MB)..." - curl -f --progress-bar -o "${PULLED_ISO}" "${NIX_ISO_URL}" || { err "Download failed"; exit 1; } + curl -f --progress-bar -o "${PULLED_IMG}" "${NIX_IMG_URL}" || { err "Download failed"; exit 1; } log "Verifying SHA256..." EXPECTED_HASH=$(curl -sf "${NIX_HASH_URL}") || { err "Failed to fetch hash"; exit 1; } - ACTUAL_HASH=$(sha256sum "${PULLED_ISO}" | cut -d' ' -f1) + ACTUAL_HASH=$(sha256sum "${PULLED_IMG}" | cut -d' ' -f1) if [ "${ACTUAL_HASH}" != "${EXPECTED_HASH}" ]; then err "SHA256 mismatch!" exit 1 @@ -1632,7 +1598,7 @@ exec qemu-system-x86_64 \ PULL_CONFIG="${PULL_DIR}/config.json" write_usb_config_file "${PULL_CONFIG}" - flash_nixos_image_to_usb "${PULLED_ISO}" "${PULL_CONFIG}" + flash_nixos_image_to_usb "${PULLED_IMG}" "${PULL_CONFIG}" rm -rf "${PULL_DIR}" ;; *) diff --git a/fedac/native/scripts/media-layout.sh b/fedac/native/scripts/media-layout.sh index 4868c200e6..f9d0e9f6b0 100644 --- a/fedac/native/scripts/media-layout.sh +++ b/fedac/native/scripts/media-layout.sh @@ -21,10 +21,18 @@ ac_media_nixos_data_size_mib() { printf '%s\n' "${AC_NIXOS_DATA_PARTITION_MIB:-512}" } +ac_media_legacy_config_size() { + printf '%s\n' "${AC_LEGACY_CONFIG_BYTES:-4096}" +} + ac_media_default_identity_json() { printf '%s' '{"handle":"","piece":"notepat","sub":"","email":""}' } +ac_media_identity_filename() { + printf '%s\n' "${AC_IDENTITY_FILENAME:-identity.bin}" +} + ac_media_bootloader_path() { local splash="${AC_SPLASH_EFI:-${MEDIA_LAYOUT_ROOT}/bootloader/splash.efi}" local splash_dir @@ -101,6 +109,29 @@ ac_media_write_identity_config() { "${json_payload}" } +ac_media_write_legacy_config() { + local out_path="$1" + local json_payload="${2:-$(ac_media_default_identity_json)}" + local total_bytes + local current_size + local pad_size + + total_bytes="$(ac_media_legacy_config_size)" + mkdir -p "$(dirname "${out_path}")" + printf '%s' "${json_payload}" > "${out_path}" + + current_size=$(stat -c%s "${out_path}") + if [ "${current_size}" -gt "${total_bytes}" ]; then + echo "Legacy config payload exceeds ${total_bytes} bytes" >&2 + return 1 + fi + + pad_size=$(( total_bytes - current_size )) + if [ "${pad_size}" -gt 0 ]; then + head -c "${pad_size}" < /dev/zero | tr '\000' ' ' >> "${out_path}" + fi +} + ac_media_stage_boot_tree() { local stage_root="$1" local kernel_path="$2" @@ -175,6 +206,39 @@ ac_media_create_fat_image() { fi } +ac_media_create_efi_disk_image() { + local stage_root="$1" + local image_path="$2" + local label="${3:-AC_ESP}" + local size_mb="${4:-}" + local esp_start=2048 + local esp_offset + + if [ -z "${size_mb}" ]; then + size_mb=$(( $(ac_media_stage_tree_size_mib "${stage_root}") + 96 )) + fi + + dd if=/dev/zero of="${image_path}" bs=1M count="${size_mb}" status=none + printf 'label: gpt\nstart=%s, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B, name="%s"\n' \ + "${esp_start}" "${label}" | + sfdisk --force --no-reread "${image_path}" >/dev/null + mkfs.vfat -F 32 --offset="${esp_start}" -n "${label}" "${image_path}" >/dev/null + + esp_offset=$(( esp_start * 512 )) + export MTOOLS_SKIP_CHECK=1 + mmd -i "${image_path}@@${esp_offset}" ::EFI ::EFI/BOOT 2>/dev/null || true + if [ -f "${stage_root}/config.json" ]; then + mcopy -o -i "${image_path}@@${esp_offset}" "${stage_root}/config.json" ::config.json + fi + if [ -f "${stage_root}/$(ac_media_identity_filename)" ]; then + mcopy -o -i "${image_path}@@${esp_offset}" "${stage_root}/$(ac_media_identity_filename)" "::$(ac_media_identity_filename)" + fi + mcopy -o -i "${image_path}@@${esp_offset}" "${stage_root}/EFI/BOOT/BOOTX64.EFI" ::EFI/BOOT/BOOTX64.EFI + if [ -f "${stage_root}/EFI/BOOT/BOOTIA32.EFI" ]; then + mcopy -o -i "${image_path}@@${esp_offset}" "${stage_root}/EFI/BOOT/BOOTIA32.EFI" ::EFI/BOOT/BOOTIA32.EFI + fi +} + ac_create_fat_boot_image() { ac_media_create_fat_image "$@" } @@ -213,6 +277,61 @@ ac_media_nixos_data_partition_start_sector() { ac_media_partition_start_sector "$1" 3 } +ac_media_generate_manifest() { + local image_path="$1" + local build_name="$2" + local out_path="$3" + local identity_size + local config_size + local identity_marker + + identity_size="$(ac_media_identity_size)" + config_size="$(ac_media_legacy_config_size)" + identity_marker="$(ac_media_identity_marker)" + + python3 - "$image_path" "$build_name" "$out_path" "$identity_size" "$config_size" "$identity_marker" <<'PYEOF' +import json +import os +import sys +from datetime import datetime, timezone + +image_path, build_name, out_path, identity_size, config_size, identity_marker = sys.argv[1:] +identity_size = int(identity_size) +config_size = int(config_size) +needle = b'{"handle":"","piece":"notepat","sub":"","email":""}' +identity_header = (identity_marker + "\n").encode() + +with open(image_path, "rb") as fh: + data = fh.read() + +identity_offset = data.find(identity_header) +config_offsets = [] +start = 0 +while True: + idx = data.find(needle, start) + if idx < 0: + break + if idx < len(identity_header) or data[idx - len(identity_header):idx] != identity_header: + config_offsets.append(idx) + start = idx + len(needle) + +manifest = { + "name": build_name, + "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "artifactType": "img", + "identityBlockOffset": identity_offset, + "identityBlockSize": identity_size, + "identityMarker": identity_marker, + "configOffsets": config_offsets, + "configPatchSize": config_size, + "imageSize": os.path.getsize(image_path), +} + +with open(out_path, "w", encoding="utf-8") as fh: + json.dump(manifest, fh, indent=2) +PYEOF +} + ac_media_customize_nixos_efi_boot() { local image_path="$1" local efi_start diff --git a/fedac/native/scripts/nixos-image-helper.sh b/fedac/native/scripts/nixos-image-helper.sh new file mode 100644 index 0000000000..60126f3138 --- /dev/null +++ b/fedac/native/scripts/nixos-image-helper.sh @@ -0,0 +1,209 @@ +#!/bin/bash +set -euo pipefail + +source /usr/local/lib/ac-media-layout.sh + +IMAGE_PATH="${1:?usage: nixos-image-helper.sh }" +CONFIG_JSON_PATH="${2:?usage: nixos-image-helper.sh }" + +log() { echo "[nixos-image-helper] $*"; } +err() { echo "[nixos-image-helper] $*" >&2; } + +part_path() { + local dev="$1" + local idx="$2" + if [[ "${dev}" =~ [0-9]$ ]]; then + printf '%sp%s\n' "${dev}" "${idx}" + else + printf '%s%s\n' "${dev}" "${idx}" + fi +} + +partition_number_for_type() { + local image_path="$1" + local type_guid="$2" + python3 - "$image_path" "$type_guid" <<'PYEOF' +import re +import subprocess +import sys + +image_path, type_guid = sys.argv[1:] +dump = subprocess.check_output(["sfdisk", "-d", image_path], text=True, stderr=subprocess.DEVNULL) +pattern = re.compile(rf"{re.escape(image_path)}(\d+)\s*:.*type=([0-9A-Fa-f\-]+)") +for line in dump.splitlines(): + match = pattern.match(line.strip()) + if match and match.group(2).lower() == type_guid.lower(): + print(match.group(1)) + raise SystemExit(0) +raise SystemExit(1) +PYEOF +} + +max_partition_number() { + local image_path="$1" + python3 - "$image_path" <<'PYEOF' +import re +import subprocess +import sys + +image_path = sys.argv[1] +dump = subprocess.check_output(["sfdisk", "-d", image_path], text=True, stderr=subprocess.DEVNULL) +pattern = re.compile(rf"{re.escape(image_path)}(\d+)\s*:") +max_idx = 0 +for line in dump.splitlines(): + match = pattern.match(line.strip()) + if match: + max_idx = max(max_idx, int(match.group(1))) +print(max_idx) +PYEOF +} + +append_partition() { + local image_path="$1" + local part_number="$2" + local size_mib="$3" + local type_guid="$4" + local part_name="$5" + local sector_size=512 + local image_bytes + local image_sectors + local start_sector + local size_sectors + + image_bytes=$(stat -c%s "${image_path}") + image_sectors=$(( (image_bytes + sector_size - 1) / sector_size )) + start_sector=$(( ((image_sectors + 2047) / 2048) * 2048 )) + size_sectors=$(( size_mib * 1024 * 1024 / sector_size )) + + truncate -s $(((start_sector + size_sectors) * sector_size)) "${image_path}" + printf 'start=%s, size=%s, type=%s, name="%s"\n' \ + "${start_sector}" "${size_sectors}" "${type_guid}" "${part_name}" | + sfdisk --no-reread -N "${part_number}" "${image_path}" >/dev/null +} + +wait_for_partition() { + local part="$1" + for _ in $(seq 1 40); do + [ -b "${part}" ] && return 0 + sleep 0.25 + done + err "Partition did not appear: ${part}" + return 1 +} + +LOOP_DEV="" +TMP_DIR="" +EFI_MOUNT="" +MAC_MOUNT="" +DATA_MOUNT="" + +cleanup() { + umount "${EFI_MOUNT:-}" 2>/dev/null || true + umount "${MAC_MOUNT:-}" 2>/dev/null || true + umount "${DATA_MOUNT:-}" 2>/dev/null || true + if [ -n "${LOOP_DEV}" ]; then + losetup -d "${LOOP_DEV}" 2>/dev/null || true + fi + rm -rf "${TMP_DIR:-}" +} +trap cleanup EXIT + +if [ ! -f "${IMAGE_PATH}" ]; then + err "Missing image: ${IMAGE_PATH}" + exit 1 +fi +if [ ! -f "${CONFIG_JSON_PATH}" ]; then + err "Missing config JSON: ${CONFIG_JSON_PATH}" + exit 1 +fi + +TMP_DIR="$(mktemp -d /tmp/ac-nixos-image.XXXXXX)" +LEGACY_CONFIG="${TMP_DIR}/config.json" +IDENTITY_FILE="${TMP_DIR}/$(ac_media_identity_filename)" + +CONFIG_JSON="$(cat "${CONFIG_JSON_PATH}")" +ac_media_write_legacy_config "${LEGACY_CONFIG}" "${CONFIG_JSON}" +ac_media_write_identity_config "${IDENTITY_FILE}" "${CONFIG_JSON}" + +EFI_PART_NUM="$(partition_number_for_type "${IMAGE_PATH}" "c12a7328-f81f-11d2-ba4b-00a0c93ec93b" || true)" +if [ -z "${EFI_PART_NUM}" ]; then + err "No EFI partition found in ${IMAGE_PATH}" + exit 1 +fi + +MAC_PART_NUM="$(partition_number_for_type "${IMAGE_PATH}" "48465300-0000-11aa-aa11-00306543ecac" || true)" +DATA_PART_NUM="$(partition_number_for_type "${IMAGE_PATH}" "ebd0a0a2-b9e5-4433-87c0-68b6b72699c7" || true)" +NEXT_PART_NUM=$(( $(max_partition_number "${IMAGE_PATH}") + 1 )) + +if [ -z "${MAC_PART_NUM}" ]; then + MAC_PART_NUM="${NEXT_PART_NUM}" + append_partition "${IMAGE_PATH}" "${MAC_PART_NUM}" "${AC_NIXOS_MAC_PARTITION_MIB:-256}" \ + "48465300-0000-11AA-AA11-00306543ECAC" "AC-MAC" + NEXT_PART_NUM=$(( NEXT_PART_NUM + 1 )) +fi + +if [ -z "${DATA_PART_NUM}" ]; then + DATA_PART_NUM="${NEXT_PART_NUM}" + append_partition "${IMAGE_PATH}" "${DATA_PART_NUM}" "$(ac_media_nixos_data_size_mib)" \ + "EBD0A0A2-B9E5-4433-87C0-68B6B72699C7" "$(ac_media_nixos_data_label)" +fi + +LOOP_DEV="$(losetup --find --show --partscan "${IMAGE_PATH}")" +EFI_PART="$(part_path "${LOOP_DEV}" "${EFI_PART_NUM}")" +MAC_PART="$(part_path "${LOOP_DEV}" "${MAC_PART_NUM}")" +DATA_PART="$(part_path "${LOOP_DEV}" "${DATA_PART_NUM}")" + +wait_for_partition "${EFI_PART}" +wait_for_partition "${MAC_PART}" +wait_for_partition "${DATA_PART}" + +if ! blkid -o value -s LABEL "${MAC_PART}" >/dev/null 2>&1; then + mkfs.hfsplus -v AC-MAC "${MAC_PART}" >/dev/null +fi +if [ "$(blkid -o value -s LABEL "${DATA_PART}" 2>/dev/null || true)" != "$(ac_media_nixos_data_label)" ]; then + mkfs.vfat -F 32 -n "$(ac_media_nixos_data_label)" "${DATA_PART}" >/dev/null +fi + +EFI_MOUNT="${TMP_DIR}/efi" +MAC_MOUNT="${TMP_DIR}/mac" +DATA_MOUNT="${TMP_DIR}/data" +mkdir -p "${EFI_MOUNT}" "${MAC_MOUNT}" "${DATA_MOUNT}" + +mount -t vfat "${EFI_PART}" "${EFI_MOUNT}" +mount -t vfat "${DATA_PART}" "${DATA_MOUNT}" +mkdir -p "${DATA_MOUNT}/logs" +cp "${LEGACY_CONFIG}" "${DATA_MOUNT}/config.json" +cp "${IDENTITY_FILE}" "${DATA_MOUNT}/$(ac_media_identity_filename)" +sync +umount "${DATA_MOUNT}" + +if mount -t hfsplus "${MAC_PART}" "${MAC_MOUNT}" 2>/dev/null; then + mkdir -p "${MAC_MOUNT}/System/Library/CoreServices" "${MAC_MOUNT}/EFI/BOOT" + cp "${EFI_MOUNT}/EFI/BOOT/BOOTX64.EFI" "${MAC_MOUNT}/System/Library/CoreServices/boot.efi" + cp "${EFI_MOUNT}/EFI/BOOT/BOOTX64.EFI" "${MAC_MOUNT}/EFI/BOOT/BOOTX64.EFI" + cat > "${MAC_MOUNT}/System/Library/CoreServices/SystemVersion.plist" <<'PLIST_EOF' + + + + + ProductBuildVersion + + ProductName + Linux + ProductVersion + AC Native OS + + +PLIST_EOF + echo "Mach Kernel" > "${MAC_MOUNT}/mach_kernel" + hfs-bless "${MAC_MOUNT}/System/Library/CoreServices/boot.efi" || true + sync + umount "${MAC_MOUNT}" +else + log "Skipping AC-MAC population; hfsplus mount unavailable" +fi + +umount "${EFI_MOUNT}" +sync +fsck.hfsplus -yrdfp "${MAC_PART}" 2>/dev/null || true +log "Prepared ${IMAGE_PATH} with AC-MAC and $(ac_media_nixos_data_label)" diff --git a/fedac/native/scripts/upload-release.sh b/fedac/native/scripts/upload-release.sh index a32eca9f7b..474cf13e20 100755 --- a/fedac/native/scripts/upload-release.sh +++ b/fedac/native/scripts/upload-release.sh @@ -3,18 +3,18 @@ # Publishes: native-notepat-latest.vmlinuz, .sha256, .version, and updates releases.json # # Usage: ./upload-release.sh [vmlinuz_path] -# ./upload-release.sh --iso [iso_path] # Upload ISO only +# ./upload-release.sh --image [image_path] # Upload template disk image only # Credentials auto-loaded from aesthetic-computer-vault/fedac/native/upload.env set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -ISO_ONLY=0 -if [ "${1:-}" = "--iso" ]; then - ISO_ONLY=1 - ISO_PATH="${2:-${SCRIPT_DIR}/../build/ac-os.iso}" - if [ ! -f "$ISO_PATH" ]; then - echo "Error: ISO not found at $ISO_PATH" >&2 +IMAGE_ONLY=0 +if [ "${1:-}" = "--image" ] || [ "${1:-}" = "--iso" ]; then + IMAGE_ONLY=1 + IMAGE_PATH="${2:-${SCRIPT_DIR}/../build/ac-os.img}" + if [ ! -f "$IMAGE_PATH" ]; then + echo "Error: image not found at $IMAGE_PATH" >&2 exit 1 fi VMLINUZ="/dev/null" # not used but needed for cred loading below @@ -162,17 +162,17 @@ if [ -n "${OTA_CHANNEL:-}" ]; then echo " channel: ${OTA_CHANNEL}" fi -# ISO-only mode: upload ISO + version + sha256, then exit -if [ "$ISO_ONLY" = "1" ]; then - ISO_SHA256=$(sha256sum "$ISO_PATH" | awk '{print $1}') - ISO_SIZE=$(stat -c%s "$ISO_PATH" 2>/dev/null || stat -f%z "$ISO_PATH") - printf '%s\n%s' "${FULL_VERSION}" "$ISO_SIZE" > "$TMP/version.txt" - printf '%s' "$ISO_SHA256" > "$TMP/sha256.txt" - echo "Uploading ISO: $(du -sh "$ISO_PATH" | cut -f1) sha256=${ISO_SHA256:0:16}..." +# Image-only mode: upload disk image + version + sha256, then exit +if [ "$IMAGE_ONLY" = "1" ]; then + IMAGE_SHA256=$(sha256sum "$IMAGE_PATH" | awk '{print $1}') + IMAGE_SIZE=$(stat -c%s "$IMAGE_PATH" 2>/dev/null || stat -f%z "$IMAGE_PATH") + printf '%s\n%s' "${FULL_VERSION}" "$IMAGE_SIZE" > "$TMP/version.txt" + printf '%s' "$IMAGE_SHA256" > "$TMP/sha256.txt" + echo "Uploading image: $(du -sh "$IMAGE_PATH" | cut -f1) sha256=${IMAGE_SHA256:0:16}..." do_upload "$TMP/version.txt" "os/${CHANNEL_PREFIX}native-notepat-latest.version" "text/plain" do_upload "$TMP/sha256.txt" "os/${CHANNEL_PREFIX}native-notepat-latest.sha256" "text/plain" - do_upload "$ISO_PATH" "os/${CHANNEL_PREFIX}native-notepat-latest.iso" "application/octet-stream" - echo "ISO published: ${BASE_URL}/os/${CHANNEL_PREFIX}native-notepat-latest.iso" + do_upload "$IMAGE_PATH" "os/${CHANNEL_PREFIX}native-notepat-latest.img" "application/octet-stream" + echo "Image published: ${BASE_URL}/os/${CHANNEL_PREFIX}native-notepat-latest.img" exit 0 fi @@ -235,11 +235,11 @@ if [ -f "$INITRAMFS_SIBLING" ]; then do_upload "$INITRAMFS_SIBLING" "os/${CHANNEL_PREFIX}native-notepat-latest.initramfs.cpio.gz" "application/octet-stream" fi -# Also upload ISO if it exists (non-fatal — ISO is optional) -ISO_SIBLING="$(dirname "$VMLINUZ")/ac-os.iso" -if [ -f "$ISO_SIBLING" ]; then - echo " Uploading ISO ($(du -sh "$ISO_SIBLING" | cut -f1))..." - do_upload "$ISO_SIBLING" "os/${CHANNEL_PREFIX}native-notepat-latest.iso" "application/octet-stream" || echo " ISO upload failed (non-fatal)" +# Also upload a template disk image if it exists (non-fatal) +IMAGE_SIBLING="$(dirname "$VMLINUZ")/ac-os.img" +if [ -f "$IMAGE_SIBLING" ]; then + echo " Uploading image ($(du -sh "$IMAGE_SIBLING" | cut -f1))..." + do_upload "$IMAGE_SIBLING" "os/${CHANNEL_PREFIX}native-notepat-latest.img" "application/octet-stream" || echo " Image upload failed (non-fatal)" fi echo "" @@ -250,6 +250,6 @@ if [ -f "$SLIM_SIBLING" ]; then echo " ${BASE_URL}/os/${CHANNEL_PREFIX}native-notepat-latest.initramfs.cpio.gz" fi echo " ${BASE_URL}/os/${CHANNEL_PREFIX}releases.json" -if [ -f "$ISO_SIBLING" ]; then - echo " ${BASE_URL}/os/${CHANNEL_PREFIX}native-notepat-latest.iso" +if [ -f "$IMAGE_SIBLING" ]; then + echo " ${BASE_URL}/os/${CHANNEL_PREFIX}native-notepat-latest.img" fi diff --git a/fedac/nixos/configuration.nix b/fedac/nixos/configuration.nix index 2f04271352..c199fc3b7e 100644 --- a/fedac/nixos/configuration.nix +++ b/fedac/nixos/configuration.nix @@ -94,6 +94,18 @@ in ExecStart = pkgs.writeShellScript "mount-usb-config" '' set -u + write_breadcrumb() { + local tag="$1" + local stamp + stamp="$(${pkgs.coreutils}/bin/date -u +%Y%m%dT%H%M%SZ)" + { + echo "tag=${tag}" + echo "stamp=${stamp}" + echo "host=ac-native" + echo "version=${gitHash}-${version}" + } > "/mnt/logs/${tag}-${stamp}.txt" + } + mkdir -p /mnt ${pkgs.systemd}/bin/udevadm settle --timeout=10 || true @@ -104,6 +116,7 @@ in -o rw,uid=1000,gid=100,umask=0077,shortname=mixed,utf8=1 \ "$dev" /mnt 2>/dev/null; then mkdir -p /mnt/logs + write_breadcrumb "boot-mounted" echo "Mounted AC Native data from $dev" exit 0 fi @@ -113,6 +126,7 @@ in chown ac:users /mnt 2>/dev/null || true chmod 0700 /mnt 2>/dev/null || true mkdir -p /mnt/logs + write_breadcrumb "boot-mounted-temporary" echo "No ACDATA partition found; /mnt is temporary" ${pkgs.util-linux}/bin/lsblk -o NAME,SIZE,TYPE,FSTYPE,LABEL,MOUNTPOINTS || true ${pkgs.util-linux}/bin/blkid || true diff --git a/fedac/nixos/flake.nix b/fedac/nixos/flake.nix index 26dcf4ddcb..e7379bb60b 100644 --- a/fedac/nixos/flake.nix +++ b/fedac/nixos/flake.nix @@ -3,16 +3,13 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - nixos-generators = { - url = "github:nix-community/nixos-generators"; - inputs.nixpkgs.follows = "nixpkgs"; - }; }; - outputs = { self, nixpkgs, nixos-generators, ... }: + outputs = { self, nixpkgs, ... }: let system = "x86_64-linux"; pkgs = import nixpkgs { inherit system; }; + lib = nixpkgs.lib; version = builtins.substring 0 8 (self.lastModifiedDate or "unknown"); gitHash = self.shortRev or "dirty"; nativeSrcPath = builtins.getEnv "AC_NIX_NATIVE_SRC"; @@ -24,6 +21,21 @@ } else throw "AC_NIX_NATIVE_SRC is required for fedac/nixos builds; run nix with --impure and point it at fedac/native."; + specialArgs = { inherit self gitHash version nativeSrc; }; + runtimeModules = [ ./configuration.nix ]; + imageModules = runtimeModules ++ [ ./modules/image.nix ]; + evalConfig = import "${nixpkgs}/nixos/lib/eval-config.nix"; + makeDiskImage = import "${nixpkgs}/nixos/lib/make-disk-image.nix"; + runtimeSystem = lib.nixosSystem { + inherit system; + modules = runtimeModules; + inherit specialArgs; + }; + imageSystem = evalConfig { + inherit system; + modules = imageModules; + inherit specialArgs; + }; in { # The ac-native binary as a standalone package @@ -32,22 +44,28 @@ inherit gitHash version nativeSrc; }; - # Bootable ISO image (no KVM needed to build) - usb-image = nixos-generators.nixosGenerate { - inherit system; - modules = [ ./configuration.nix ]; - format = "iso"; - specialArgs = { inherit self gitHash version nativeSrc; }; + # Bootable raw disk image with BIOS + UEFI bootloader install. + # In nixpkgs make-disk-image, "hybrid" is GPT with an ESP plus + # a bios_grub partition, not a hybrid MBR. + usb-image = makeDiskImage { + inherit pkgs lib; + config = imageSystem.config; + format = "raw"; + onlyNixStore = false; + partitionTableType = "hybrid"; + installBootLoader = true; + touchEFIVars = false; + copyChannel = false; + diskSize = "auto"; + additionalSpace = "2G"; + memSize = 4096; }; default = self.packages.${system}.usb-image; }; # Full NixOS system configuration - nixosConfigurations.ac-native-os = nixpkgs.lib.nixosSystem { - inherit system; - modules = [ ./configuration.nix ]; - specialArgs = { inherit self gitHash version nativeSrc; }; - }; + nixosConfigurations.ac-native-os = runtimeSystem; + nixosConfigurations.ac-native-image = imageSystem; }; } diff --git a/fedac/nixos/modules/image.nix b/fedac/nixos/modules/image.nix new file mode 100644 index 0000000000..e06c65160d --- /dev/null +++ b/fedac/nixos/modules/image.nix @@ -0,0 +1,27 @@ +{ lib, ... }: + +{ + # Raw disk image builds need a full installed bootloader, not the live ISO path. + boot.loader.systemd-boot.enable = lib.mkForce false; + boot.loader.efi.canTouchEfiVariables = lib.mkForce false; + boot.loader.grub = { + enable = true; + device = "/dev/vda"; + efiSupport = true; + efiInstallAsRemovable = true; + configurationLimit = 1; + }; + boot.loader.timeout = lib.mkForce 0; + boot.loader.grub.timeoutStyle = lib.mkForce "hidden"; + + # Make early boot text visible on the real display for debug + fallback boot paths. + boot.kernelParams = lib.mkAfter [ "console=tty0" ]; + + # Let make-disk-image perform a full NixOS install onto a raw disk image. + fileSystems."/" = lib.mkForce { + device = "/dev/vda"; + fsType = "ext4"; + autoFormat = true; + }; + virtualisation.useBootLoader = lib.mkForce true; +} diff --git a/fedac/nixos/modules/kiosk.nix b/fedac/nixos/modules/kiosk.nix index efcc365a5f..0839dfc959 100644 --- a/fedac/nixos/modules/kiosk.nix +++ b/fedac/nixos/modules/kiosk.nix @@ -2,10 +2,31 @@ let ac-native = pkgs.callPackage ../packages/ac-native { inherit gitHash version nativeSrc; }; + write-breadcrumb = pkgs.writeShellScript "ac-native-write-breadcrumb" '' + set -u + + [ $# -ge 1 ] || exit 0 + [ -d /mnt/logs ] || exit 0 + + tag="$1" + shift || true + stamp="$(${pkgs.coreutils}/bin/date -u +%Y%m%dT%H%M%SZ)" + out="/mnt/logs/${tag}-${stamp}.txt" + { + echo "tag=${tag}" + echo "stamp=${stamp}" + echo "version=${gitHash}-${version}" + for entry in "$@"; do + echo "$entry" + done + } > "$out" 2>/dev/null || true + sync || true + ''; ac-native-client = pkgs.writeShellScript "ac-native-cage-client" '' set -u rm -f /tmp/ac-native-cage.log + ${write-breadcrumb} ac-native-starting "binary=${ac-native}/bin/ac-native" "piece=${ac-native}/share/ac-native/piece.mjs" printf '[ac-native-cage-client] starting %s %s\n' \ "${ac-native}/bin/ac-native" \ "${ac-native}/share/ac-native/piece.mjs" >&2 @@ -29,6 +50,7 @@ let set -u rm -f /tmp/cage-stderr.log + ${write-breadcrumb} kiosk-launching "tty=/dev/tty1" "display=cage" printf '[ac-native-kiosk] launching cage on tty1\n' >&2 status=0 diff --git a/lith/server.mjs b/lith/server.mjs index e5698bfb3c..51d6a5f7f1 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -735,7 +735,7 @@ app.post("/api/os-release-upload", async (req, res) => { if (req.headers["x-template-upload"] === "true") { try { - return res.json({ step: "template-upload", iso_put_url: presignUrl("os/native-notepat-latest.iso", "application/x-iso9660-image"), user: userSub }); + return res.json({ step: "template-upload", image_put_url: presignUrl("os/native-notepat-latest.img", "application/octet-stream"), user: userSub }); } catch (err) { return res.status(500).json({ error: `Template presign failed: ${err.message}` }); } @@ -755,13 +755,19 @@ app.get("/api/os-image", async (req, res) => { if (!authHeader) return res.status(401).json({ error: "Authorization required. Log in at aesthetic.computer first." }); try { - const ovenRes = await fetch("https://oven.aesthetic.computer/os-image", { + const search = new URLSearchParams(req.query || {}).toString(); + const ovenUrl = "https://oven.aesthetic.computer/os-image" + (search ? `?${search}` : ""); + const ovenRes = await fetch(ovenUrl, { headers: { Authorization: authHeader }, }); res.status(ovenRes.status); res.set("Content-Type", ovenRes.headers.get("content-type") || "application/octet-stream"); if (ovenRes.headers.get("content-disposition")) res.set("Content-Disposition", ovenRes.headers.get("content-disposition")); if (ovenRes.headers.get("content-length")) res.set("Content-Length", ovenRes.headers.get("content-length")); + if (ovenRes.headers.get("x-ac-os-requested-layout")) res.set("X-AC-OS-Requested-Layout", ovenRes.headers.get("x-ac-os-requested-layout")); + if (ovenRes.headers.get("x-ac-os-layout")) res.set("X-AC-OS-Layout", ovenRes.headers.get("x-ac-os-layout")); + if (ovenRes.headers.get("x-ac-os-fallback")) res.set("X-AC-OS-Fallback", ovenRes.headers.get("x-ac-os-fallback")); + if (ovenRes.headers.get("x-ac-os-fallback-reason")) res.set("X-AC-OS-Fallback-Reason", ovenRes.headers.get("x-ac-os-fallback-reason")); res.set("Access-Control-Allow-Origin", "*"); const { Readable } = await import("stream"); Readable.fromWeb(ovenRes.body).pipe(res); diff --git a/oven-edge/worker.mjs b/oven-edge/worker.mjs index 9ee3106dff..95b0b7e075 100644 --- a/oven-edge/worker.mjs +++ b/oven-edge/worker.mjs @@ -1,10 +1,11 @@ // oven-edge — Cloudflare Worker that serves AC OS images from the edge. -// Template ISOs are cached in R2 (or DO Spaces fallback). Personalized -// images are patched on-the-fly by overwriting the 32KB identity block. +// Template disk images are cached in R2 (or DO Spaces fallback). Personalized +// images are patched on-the-fly by overwriting the identity block and +// legacy config placeholder. // // Routes: -// /os/latest.iso → latest template ISO (cached 24h at edge) -// /os/.iso → specific build ISO (cached 24h) +// /os/latest.img → latest template image (cached 24h at edge) +// /os/.img → specific build image (cached 24h) // /os/.vmlinuz → specific build kernel (cached 24h) // /os-releases → build list (cached 1 min) // /os-image → personalized image (streaming patch at edge) @@ -15,6 +16,8 @@ const SPACES = "https://releases-aesthetic-computer.sfo3.digitaloceanspaces.com" const IDENTITY_MARKER = "AC_IDENTITY_BLOCK_V1\n"; const IDENTITY_BLOCK_SIZE = 32768; +const CONFIG_MARKER = '{"handle":"","piece":"notepat","sub":"","email":""}'; +const DEFAULT_CONFIG_PATCH_SIZE = 4096; function edgeHeaders(request, extra = {}) { return { @@ -44,10 +47,18 @@ function makeIdentityBlock(config) { return block; } -// Stream a template ISO from source, patching the identity block on-the-fly -function streamPatchedISO(templateBody, identityBlock, manifest) { - const offset = manifest.identityBlockOffset; - const size = IDENTITY_BLOCK_SIZE; +function makeLegacyConfigBlock(config, size = DEFAULT_CONFIG_PATCH_SIZE) { + const json = JSON.stringify(config); + const encoder = new TextEncoder(); + const jsonBytes = encoder.encode(json); + const block = new Uint8Array(size); + block.fill(0x20); + block.set(jsonBytes.subarray(0, size)); + return block; +} + +// Stream a template image from source, patching configured ranges on-the-fly. +function streamPatchedImage(templateBody, patches) { let bytesSeen = 0; const { readable, writable } = new TransformStream({ @@ -56,21 +67,27 @@ function streamPatchedISO(templateBody, identityBlock, manifest) { const chunkEnd = bytesSeen + chunk.byteLength; bytesSeen = chunkEnd; - // Fast path: chunk doesn't overlap identity block - if (chunkEnd <= offset || chunkStart >= offset + size) { + let buf = null; + for (const patch of patches) { + const offset = patch.offset; + const size = patch.bytes.byteLength; + if (offset < 0 || chunkEnd <= offset || chunkStart >= offset + size) { + continue; + } + if (!buf) buf = new Uint8Array(chunk); + const patchStart = Math.max(0, offset - chunkStart); + const patchOffset = Math.max(0, chunkStart - offset); + const patchLen = Math.min(size - patchOffset, buf.length - patchStart); + buf.set( + patch.bytes.subarray(patchOffset, patchOffset + patchLen), + patchStart, + ); + } + + if (!buf) { controller.enqueue(chunk); return; } - - // Slow path: chunk overlaps identity block — patch it - const buf = new Uint8Array(chunk); - const patchStart = Math.max(0, offset - chunkStart); - const patchOffset = Math.max(0, chunkStart - offset); - const patchLen = Math.min(size - patchOffset, buf.length - patchStart); - buf.set( - identityBlock.subarray(patchOffset, patchOffset + patchLen), - patchStart, - ); controller.enqueue(buf); }, }); @@ -92,30 +109,30 @@ async function getManifest(env) { return null; } -// Get template ISO body as a ReadableStream +// Get template image body as a ReadableStream async function getTemplateStream(env, manifest) { const buildName = manifest?.name; if (!buildName) return null; // Try R2 first if (env?.OS_IMAGES) { - const obj = await env.OS_IMAGES.get(`builds/${buildName}/template.iso`); + const obj = await env.OS_IMAGES.get(`builds/${buildName}/template.img`); if (obj) { const size = Number(obj.size || 0); - if (!manifest?.isoSize || !size || size === manifest.isoSize) { - return { body: obj.body, size: size || manifest?.isoSize || 0 }; + if (!manifest?.imageSize || !size || size === manifest.imageSize) { + return { body: obj.body, size: size || manifest?.imageSize || 0 }; } } } // Fallback: fetch from DO Spaces (with edge caching) - const res = await fetch(SPACES + "/os/native-notepat-latest.iso", { + const res = await fetch(SPACES + "/os/native-notepat-latest.img", { cf: { cacheTtl: 86400, cacheEverything: true }, }); if (res.ok) { const size = Number(res.headers.get("content-length") || "0"); - if (!manifest?.isoSize || !size || size === manifest.isoSize) { - return { body: res.body, size: size || manifest?.isoSize || 0 }; + if (!manifest?.imageSize || !size || size === manifest.imageSize) { + return { body: res.body, size: size || manifest?.imageSize || 0 }; } } @@ -132,7 +149,7 @@ export default { return new Response(null, { headers: edgeHeaders(request) }); } - // --- /os-image → personalized ISO (streaming edge patch) --- + // --- /os-image → personalized image (streaming edge patch) --- if (path === "/os-image") { const auth = request.headers.get("Authorization") || ""; if (!auth) { @@ -155,10 +172,12 @@ export default { } const config = await configRes.json(); - // 2. Get manifest (has identity block offset) + // 2. Get manifest (has patch offsets) const manifest = await getManifest(env); - if (!manifest || manifest.identityBlockOffset < 0) { - // No manifest or no offset — fall through to oven origin for legacy patching + const hasIdentity = Number.isFinite(manifest?.identityBlockOffset) && manifest.identityBlockOffset >= 0; + const configOffsets = Array.isArray(manifest?.configOffsets) ? manifest.configOffsets : []; + if (!manifest || (!hasIdentity && configOffsets.length === 0)) { + // No manifest or no offsets — fall through to oven origin for legacy patching const ovenRes = await fetch(ORIGIN + "/os-image" + url.search, { headers: { Authorization: auth }, }); @@ -175,38 +194,54 @@ export default { return applyHeaders(ovenRes, request, { "X-Patch": "origin-fallback" }); } - // 4. Build identity block and stream with patch - const identityBlock = makeIdentityBlock(config); - const patched = streamPatchedISO(template.body, identityBlock, manifest); + // 4. Build patch payloads and stream with patch + const patches = []; + if (hasIdentity) { + patches.push({ + offset: manifest.identityBlockOffset, + bytes: makeIdentityBlock(config), + }); + } + const configBlock = makeLegacyConfigBlock( + config, + Number(manifest.configPatchSize || DEFAULT_CONFIG_PATCH_SIZE), + ); + for (const offset of configOffsets) { + if (Number.isFinite(offset) && offset >= 0) { + patches.push({ offset, bytes: configBlock }); + } + } + patches.sort((a, b) => a.offset - b.offset); + const patched = streamPatchedImage(template.body, patches); const handle = config.handle || "unknown"; - const filename = `@${handle}-os-${config.piece || "notepat"}-AC-${manifest.name}.iso`; - const requestedLayout = (url.searchParams.get("layout") || "iso").toLowerCase(); + const filename = `@${handle}-os-${config.piece || "notepat"}-AC-${manifest.name}.img`; + const requestedLayout = (url.searchParams.get("layout") || "img").toLowerCase(); return new Response(patched, { headers: { ...edgeHeaders(request), - "Content-Type": "application/x-iso9660-image", + "Content-Type": "application/octet-stream", "Content-Disposition": `attachment; filename="${filename}"`, - "Content-Length": String(template.size || manifest.isoSize), + "Content-Length": String(template.size || manifest.imageSize), "X-AC-OS-Requested-Layout": requestedLayout, - "X-AC-OS-Layout": "iso", + "X-AC-OS-Layout": "img", "X-Build": manifest.name, "X-Patch": "edge", }, }); } - // --- /os/latest.iso → latest template ISO from DO Spaces --- - if (path === "/os/latest.iso" || path === "/os-template-iso") { - const isoUrl = SPACES + "/os/native-notepat-latest.iso"; - const res = await fetch(isoUrl, { + // --- /os/latest.img → latest template image from DO Spaces --- + if (path === "/os/latest.img" || path === "/os-template-img") { + const imgUrl = SPACES + "/os/native-notepat-latest.img"; + const res = await fetch(imgUrl, { cf: { cacheTtl: 86400, cacheEverything: true }, }); const out = new Response(res.body, res); out.headers.set( "Content-Disposition", - "attachment; filename=ac-os-latest.iso", + "attachment; filename=ac-os-latest.img", ); for (const [k, v] of Object.entries(edgeHeaders(request))) out.headers.set(k, v); @@ -233,12 +268,12 @@ export default { return out; } - // --- /os/.iso → named build ISO from DO Spaces --- - const isoMatch = path.match(/^\/os\/([a-z]+-[a-z]+)\.iso$/); - if (isoMatch) { - const name = isoMatch[1]; - const isoUrl = SPACES + "/os/builds/" + name + ".iso"; - const res = await fetch(isoUrl, { + // --- /os/.img → named build image from DO Spaces --- + const imgMatch = path.match(/^\/os\/([a-z]+-[a-z]+)\.img$/); + if (imgMatch) { + const name = imgMatch[1]; + const imgUrl = SPACES + "/os/builds/" + name + ".img"; + const res = await fetch(imgUrl, { cf: { cacheTtl: 86400, cacheEverything: true }, }); if (!res.ok) @@ -246,7 +281,7 @@ export default { const out = new Response(res.body, res); out.headers.set( "Content-Disposition", - "attachment; filename=" + name + ".iso", + "attachment; filename=" + name + ".img", ); for (const [k, v] of Object.entries(edgeHeaders(request))) out.headers.set(k, v); diff --git a/oven/native-builder.mjs b/oven/native-builder.mjs index c7d9e7dcb5..cedad10768 100644 --- a/oven/native-builder.mjs +++ b/oven/native-builder.mjs @@ -34,6 +34,8 @@ const NATIVE_DIR = process.env.NATIVE_DIR || "/opt/oven/native-git/fedac/native"; const NATIVE_BRANCH = process.env.NATIVE_GIT_BRANCH || "main"; const NIX_DATA_PARTITION_MIB = process.env.NIX_DATA_PARTITION_MIB || "512"; +const MEDIA_HELPER_IMAGE = + process.env.AC_MEDIA_HELPER_IMAGE || "ac-os-media-helper:img-v1"; const NIX_BIN_CANDIDATES = [ process.env.NIX_BIN || "", "/usr/local/bin/nix", @@ -604,7 +606,7 @@ async function runBuildJob(job) { if (progressCallback) progressCallback(makeSnapshot(job)); // fedac/nixos reads AC_NIX_NATIVE_SRC from the host env to import fedac/native. - // Build the NixOS ISO image + // Build the raw NixOS disk image. await runPhase(job, "nix-build", nixBin, [ "build", ".#usb-image", "--impure", @@ -618,48 +620,50 @@ async function runBuildJob(job) { .reverse() .find((entry) => entry.stream === "stdout" && - /^\/nix\/store\/.+\.iso$/.test(entry.line || "") + /^\/nix\/store\/.+$/.test(entry.line || "") )?.line || ""; if (!nixOutResult) { throw new Error("NixOS build finished without returning an output path"); } - // Find the ISO in the output directory. - const isoPath = await runSync( + // Find the raw disk image in the output directory. + const imgPath = await runSync( "bash", - ["-lc", "find \"$1\" -name '*.iso' -type f | head -1", "_", nixOutResult], + ["-lc", "find \"$1\" -name '*.img' -type f | head -1", "_", nixOutResult], nixosDir, ); - if (!isoPath) { - throw new Error("NixOS build produced no ISO file"); + if (!imgPath) { + throw new Error("NixOS build produced no image file"); } - addLogLine(job, "stdout", `NixOS image: ${isoPath}`); + addLogLine(job, "stdout", `NixOS image: ${imgPath}`); // Copy to upload directory await fs.mkdir(nixUploadDir, { recursive: true }); - const nixIsoUpload = path.join(nixUploadDir, "ac-os-nixos.iso"); + const nixImgUpload = path.join(nixUploadDir, "ac-os-nixos.img"); const nixConfigUpload = path.join(nixUploadDir, "config.json"); - await fs.copyFile(isoPath, nixIsoUpload); + await fs.copyFile(imgPath, nixImgUpload); await fs.writeFile( nixConfigUpload, `${JSON.stringify({ handle: "", piece: "notepat", sub: "", email: "" })}\n`, ); - addLogLine(job, "stdout", "Phase N: appending writable ACDATA partition..."); - await runPhase(job, "nix-package", "bash", [ + addLogLine(job, "stdout", "Phase N: building media helper image..."); + await runPhase(job, "nix-helper-build", "docker", [ + "build", "-t", MEDIA_HELPER_IMAGE, + "-f", path.join(repoDir, "fedac/native/Dockerfile.flash-helper"), + repoDir, + ], repoDir); + + addLogLine(job, "stdout", "Phase N: appending AC-MAC + ACDATA partitions..."); + await runPhase(job, "nix-package", "docker", [ + "run", "--rm", "--privileged", + "-v", `${nixUploadDir}:/work`, + "--entrypoint", "/bin/bash", + MEDIA_HELPER_IMAGE, "-lc", - [ - "set -euo pipefail", - `. "${path.join(NATIVE_DIR, "scripts/media-layout.sh")}"`, - `ac_media_ensure_nixos_data_partition "$1" "$2" "${NIX_DATA_PARTITION_MIB}"`, - 'ac_media_customize_nixos_efi_boot "$1"', - 'sfdisk -d "$1"', - ].join("\n"), - "_", - nixIsoUpload, - nixConfigUpload, + "exec /usr/local/bin/ac-os-nixos-image-helper /work/ac-os-nixos.img /work/config.json", ], nixUploadDir, nixEnv); job.stage = "nix-upload"; @@ -667,7 +671,7 @@ async function runBuildJob(job) { // Upload with nix- channel prefix await runPhase(job, "nix-upload", "bash", [ - uploadScript, "--iso", nixIsoUpload, + uploadScript, "--image", nixImgUpload, ], NATIVE_DIR, { ...uploadEnv, OTA_CHANNEL: "nix", diff --git a/oven/server.mjs b/oven/server.mjs index ba69984375..10d694d14c 100644 --- a/oven/server.mjs +++ b/oven/server.mjs @@ -2997,11 +2997,11 @@ app.post('/os-cache-flush', (req, res) => { res.json({ flushed: true }); }); -// Personalized FedAC OS .iso download for authenticated AC users. -// Downloads the template .iso from DO Spaces, patches config.json in-place, +// Personalized FedAC OS .img download for authenticated AC users. +// Downloads the template .img from DO Spaces, patches config.json in-place, // and streams back. Compatible with Fedora Media Writer, Balena Etcher, dd. const RELEASES_BASE = 'https://releases-aesthetic-computer.sfo3.digitaloceanspaces.com/os'; -const TEMPLATE_ISO_URL = `${RELEASES_BASE}/native-notepat-latest.iso`; +const TEMPLATE_IMG_URL = `${RELEASES_BASE}/native-notepat-latest.img`; const TEMPLATE_GZ_URL = `${RELEASES_BASE}/native-notepat-latest.img.gz`; // legacy fallback const TEMPLATE_VMLINUZ_URL = `${RELEASES_BASE}/native-notepat-latest.vmlinuz`; const TEMPLATE_CL_VMLINUZ_URL = `${RELEASES_BASE}/cl-native-notepat-latest.vmlinuz`; @@ -3019,21 +3019,21 @@ async function getTemplate() { if (templateCache && Date.now() - templateCacheTime < TEMPLATE_CACHE_TTL) { return templateCache; } - // Try .iso first, fall back to legacy .img.gz + // Try the raw .img first, fall back to the older compressed image if needed. let raw; - const isoRes = await fetch(TEMPLATE_ISO_URL); - if (isoRes.ok) { - console.log('[os-image] Downloading template .iso...'); - raw = Buffer.from(await isoRes.arrayBuffer()); + const imgRes = await fetch(TEMPLATE_IMG_URL); + if (imgRes.ok) { + console.log('[os-image] Downloading template .img...'); + raw = Buffer.from(await imgRes.arrayBuffer()); } else { - console.log('[os-image] No .iso found, trying legacy .img.gz fallback...'); + console.log('[os-image] No .img found, trying legacy .img.gz fallback...'); const gzRes = await fetch(TEMPLATE_GZ_URL); if (gzRes.ok) { const compressed = Buffer.from(await gzRes.arrayBuffer()); console.log(`[os-image] Decompressing ${(compressed.length / 1048576).toFixed(1)}MB...`); raw = gunzipSync(compressed); } else { - throw new Error(`Template download failed (no .iso or .img.gz available)`); + throw new Error(`Template download failed (no .img or .img.gz available)`); } } templateCache = raw; @@ -3089,7 +3089,7 @@ async function buildPersonalizedEfiImage({ kernelUrl, configJson }) { } } -// User config endpoint for edge worker ISO patching +// User config endpoint for edge worker image patching app.get('/api/user-config', async (req, res) => { const authHeader = req.headers.authorization || ''; const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; @@ -3152,7 +3152,6 @@ app.get('/api/user-config', async (req, res) => { }); app.get('/os-image', async (req, res) => { - // Auth: verify AC token const authHeader = req.headers.authorization || ''; const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''; if (!token) { @@ -3170,7 +3169,6 @@ app.get('/os-image', async (req, res) => { return res.status(401).json({ error: `Authentication failed: ${err.message}` }); } - // Look up handle by sub (avoids stale /user cache for new handles) let handle = ''; const sub = userInfo.sub || ''; try { @@ -3187,16 +3185,14 @@ app.get('/os-image', async (req, res) => { return res.status(403).json({ error: 'You need a handle first. Visit aesthetic.computer/handle to claim one.' }); } - // Boot-to piece preference (default: notepat) const ALLOWED_PIECES = ['notepat', 'prompt', 'chat', 'laer-klokken']; const reqPiece = req.query.piece || 'notepat'; const bootPiece = ALLOWED_PIECES.includes(reqPiece) ? reqPiece : 'notepat'; - - // WiFi/internet toggle (default: enabled) const wifiParam = req.query.wifi; const wifiEnabled = wifiParam !== '0' && wifiParam !== 'false'; + const requestedLayout = String(req.query.layout || 'img').toLowerCase(); + const variant = String(req.query.variant || '').toLowerCase() === 'cl' ? 'cl' : 'c'; - // Fetch device tokens (Claude + GitHub) from DB let claudeToken = '', githubPat = ''; try { const mongoUri = process.env.MONGODB_CONNECTION_STRING; @@ -3216,17 +3212,8 @@ app.get('/os-image', async (req, res) => { console.warn(`[os-image] Token lookup failed: ${err.message}`); } - console.log(`[os-image] Building personalized image for @${handle} (boot: ${bootPiece}, wifi: ${wifiEnabled}, claude: ${!!claudeToken}, git: ${!!githubPat})`); + console.log(`[os-image] Building personalized image for @${handle} (boot: ${bootPiece}, wifi: ${wifiEnabled}, variant: ${variant}, claude: ${!!claudeToken}, git: ${!!githubPat})`); - const variant = String(req.query.variant || '').toLowerCase() === 'cl' ? 'cl' : 'c'; - const layout = String(req.query.layout || '').toLowerCase(); - const preferEfiLayout = layout === 'efi' || layout === 'img' || layout === 'raw'; - const strictEfi = - preferEfiLayout && - String(req.query.strict || '1').toLowerCase() !== '0' && - String(req.query.strict || '1').toLowerCase() !== 'false'; - - // Build personalized config JSON const configObj = { handle, piece: bootPiece, @@ -3239,95 +3226,54 @@ app.get('/os-image', async (req, res) => { if (!wifiEnabled) configObj.wifi = false; const configJson = JSON.stringify(configObj); - // Build a direct EFI image (single ESP partition) when requested. - // This layout matches local ac-os flash and is more firmware-compatible - // than some hybrid ISO scanners on older BIOS/UEFI implementations. - let imgData = null; - let contentType = 'application/x-iso9660-image'; - let extension = 'iso'; - let servedLayout = 'iso'; - let efiError = null; - if (preferEfiLayout) { - try { - const kernelUrl = kernelUrlForVariant(variant); - imgData = await buildPersonalizedEfiImage({ kernelUrl, configJson }); - contentType = 'application/octet-stream'; - extension = 'img'; - servedLayout = 'efi'; - console.log( - `[os-image] Built EFI image for @${handle} (${(imgData.length / 1048576).toFixed(1)}MB, variant=${variant})`, - ); - } catch (err) { - efiError = err; - console.warn(`[os-image] EFI layout build failed, falling back to ISO patch path: ${err.message}`); - if (strictEfi) { - return res.status(503).json({ - error: `EFI layout build failed: ${err.message}`, - requestedLayout: 'efi', - }); - } - } - } - - // Fallback: patch the template ISO in-place - if (!imgData) { - try { - const template = await getTemplate(); - imgData = Buffer.from(template); // copy so we don't mutate cache - } catch (err) { - return res.status(503).json({ error: `Template not available: ${err.message}` }); - } - - // Try new identity block format first (32KB, marker-prefixed) - const identityMarkerBuf = Buffer.from(IDENTITY_MARKER + '\n'); - let idx = imgData.indexOf(identityMarkerBuf); - let patchCount = 0; - - if (idx !== -1) { - // New format: marker + newline + JSON + zero-padding to 32KB - while (idx !== -1) { - const block = Buffer.alloc(IDENTITY_BLOCK_SIZE, 0); - const header = Buffer.from(IDENTITY_MARKER + '\n' + configJson); - header.copy(block); - block.copy(imgData, idx); - patchCount++; - idx = imgData.indexOf(identityMarkerBuf, idx + IDENTITY_BLOCK_SIZE); - } - console.log(`[os-image] Patched ${patchCount} identity block(s) for @${handle} (v1, 32KB)`); - } else { - // Legacy format: plain JSON padded to 4KB with spaces - const legacyMarkerBuf = Buffer.from(CONFIG_MARKER_LEGACY); - idx = imgData.indexOf(legacyMarkerBuf); - if (idx === -1) { - return res.status(500).json({ error: 'Template image missing config placeholder' }); - } - const padded = configJson + ' '.repeat(Math.max(0, CONFIG_PAD_SIZE_LEGACY - configJson.length)); - const configBytes = Buffer.from(padded); - while (idx !== -1) { - configBytes.copy(imgData, idx); - patchCount++; - idx = imgData.indexOf(legacyMarkerBuf, idx + CONFIG_PAD_SIZE_LEGACY); - } - console.log(`[os-image] Patched ${patchCount} config location(s) for @${handle} (legacy, 4KB)`); - } - } + let imgData; + try { + const template = await getTemplate(); + imgData = Buffer.from(template); + } catch (err) { + return res.status(503).json({ error: `Template not available: ${err.message}` }); + } + + const identityMarkerBuf = Buffer.from(IDENTITY_MARKER + '\n'); + let idx = imgData.indexOf(identityMarkerBuf); + let identityPatchCount = 0; + while (idx !== -1) { + const block = Buffer.alloc(IDENTITY_BLOCK_SIZE, 0); + const header = Buffer.from(IDENTITY_MARKER + '\n' + configJson); + header.copy(block); + block.copy(imgData, idx); + identityPatchCount++; + idx = imgData.indexOf(identityMarkerBuf, idx + IDENTITY_BLOCK_SIZE); + } + + const padded = configJson.length >= CONFIG_PAD_SIZE_LEGACY + ? configJson.slice(0, CONFIG_PAD_SIZE_LEGACY) + : configJson + ' '.repeat(CONFIG_PAD_SIZE_LEGACY - configJson.length); + const configBytes = Buffer.from(padded); + const legacyMarkerBuf = Buffer.from(CONFIG_MARKER_LEGACY); + let configPatchCount = 0; + idx = imgData.indexOf(legacyMarkerBuf); + while (idx !== -1) { + configBytes.copy(imgData, idx); + configPatchCount++; + idx = imgData.indexOf(legacyMarkerBuf, idx + CONFIG_PAD_SIZE_LEGACY); + } + + if (identityPatchCount === 0 && configPatchCount === 0) { + return res.status(500).json({ error: 'Template image missing config placeholder' }); + } + console.log( + `[os-image] Patched ${identityPatchCount} identity block(s) and ${configPatchCount} config location(s) for @${handle}`, + ); - // Stream the personalized image (ISO patch path or EFI-first image path) addServerLog('success', '💿', `OS image for @${handle} (${(imgData.length / 1048576).toFixed(1)}MB)`); - if (preferEfiLayout && servedLayout !== 'efi') { - addServerLog('warn', '⚠️', `OS image fallback for @${handle}: requested EFI but served ISO`); - } - res.setHeader('Content-Type', contentType); + res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); - res.setHeader('X-AC-OS-Requested-Layout', preferEfiLayout ? 'efi' : 'iso'); - res.setHeader('X-AC-OS-Layout', servedLayout); - if (efiError) { - res.setHeader('X-AC-OS-Fallback', '1'); - res.setHeader('X-AC-OS-Fallback-Reason', String(efiError.message || 'unknown').slice(0, 180)); - } - // Get latest build name for filename + res.setHeader('X-AC-OS-Requested-Layout', requestedLayout || 'img'); + res.setHeader('X-AC-OS-Layout', 'img'); + let releaseName = 'native'; try { const relRes = await fetch(`${RELEASES_BASE}/releases.json`); @@ -3340,7 +3286,7 @@ app.get('/os-image', async (req, res) => { const d = new Date(); const p = (n) => String(n).padStart(2, '0'); const ts = `${d.getFullYear()}.${p(d.getMonth()+1)}.${p(d.getDate())}.${p(d.getHours())}.${p(d.getMinutes())}.${p(d.getSeconds())}`; - res.setHeader('Content-Disposition', `attachment; filename="@${handle}-os-${bootPiece}-${coreName}-${ts}.${extension}"`); + res.setHeader('Content-Disposition', `attachment; filename="@${handle}-os-${bootPiece}-${coreName}-${ts}.img"`); res.setHeader('Content-Length', imgData.length); res.end(imgData); }); diff --git a/system/netlify/edge-functions/os-image.js b/system/netlify/edge-functions/os-image.js index 88c7260b07..1bed0b13ec 100644 --- a/system/netlify/edge-functions/os-image.js +++ b/system/netlify/edge-functions/os-image.js @@ -1,8 +1,8 @@ // os-image — Proxy to oven for personalized FedAC OS image downloads. -// Oven handles the heavy lifting (42MB template download, config patching, streaming). +// Oven handles the heavy lifting (template download, config patching, streaming). // This edge function just forwards the auth header and streams the response. -const OVEN_URL = "https://oven.aesthetic.computer/os-image"; +const OVEN_BASE = "https://oven.aesthetic.computer/os-image"; export default async (req) => { if (req.method === "OPTIONS") { @@ -29,7 +29,8 @@ export default async (req) => { // Proxy to oven try { - const ovenRes = await fetch(OVEN_URL, { + const ovenUrl = OVEN_BASE + new URL(req.url).search; + const ovenRes = await fetch(ovenUrl, { headers: { Authorization: authHeader }, }); @@ -40,6 +41,12 @@ export default async (req) => { "Content-Type": ovenRes.headers.get("content-type") || "application/octet-stream", "Content-Disposition": ovenRes.headers.get("content-disposition") || "", "Content-Length": ovenRes.headers.get("content-length") || "", + "X-AC-OS-Requested-Layout": + ovenRes.headers.get("x-ac-os-requested-layout") || "", + "X-AC-OS-Layout": ovenRes.headers.get("x-ac-os-layout") || "", + "X-AC-OS-Fallback": ovenRes.headers.get("x-ac-os-fallback") || "", + "X-AC-OS-Fallback-Reason": + ovenRes.headers.get("x-ac-os-fallback-reason") || "", "Access-Control-Allow-Origin": "*", }, }); diff --git a/system/netlify/edge-functions/os-release-upload.js b/system/netlify/edge-functions/os-release-upload.js index 7ad23e91de..e7ccc49685 100644 --- a/system/netlify/edge-functions/os-release-upload.js +++ b/system/netlify/edge-functions/os-release-upload.js @@ -301,17 +301,17 @@ export default async (request) => { } } - // Template .iso presigned URL (separate from vmlinuz) + // Template .img presigned URL (separate from vmlinuz) const isTemplate = request.headers.get("x-template-upload") === "true"; if (isTemplate) { try { - const isoUrl = await presignUrl( - "os/native-notepat-latest.iso", - "application/x-iso9660-image", + const imageUrl = await presignUrl( + "os/native-notepat-latest.img", + "application/octet-stream", ); return Response.json({ step: "template-upload", - iso_put_url: isoUrl, + image_put_url: imageUrl, user: userSub, }); } catch (err) { diff --git a/system/public/aesthetic.computer/disks/os.mjs b/system/public/aesthetic.computer/disks/os.mjs index 9fd7900fdd..9a02420366 100644 --- a/system/public/aesthetic.computer/disks/os.mjs +++ b/system/public/aesthetic.computer/disks/os.mjs @@ -4,16 +4,16 @@ const OVEN = "https://oven-edge.aesthetic-computer.workers.dev"; const OVEN_WS = "wss://oven.aesthetic.computer/ws"; const OVEN_ORIGIN = "https://oven.aesthetic.computer"; -const ISO_BASE = OVEN + "/os/latest.iso"; +const IMAGE_BASE = OVEN + "/os/latest.img"; function OVEN_BASE() { return OVEN; } function RELEASES_URL() { return OVEN + "/os-releases"; } function OVEN_WS_URL() { return OVEN_WS; } -function templateIsoUrl() { - const base = ISO_BASE; - if (variantIdx === 0) return base; - // CL variant: replace 'native-notepat' with 'cl-native-notepat' - return base.replace("native-notepat", "cl-native-notepat"); -} +function templateImageUrl() { + const base = IMAGE_BASE; + if (variantIdx === 0) return base; + // CL variant: replace 'native-notepat' with 'cl-native-notepat' + return base.replace("native-notepat", "cl-native-notepat"); +} const CONFIG_MARKER = '{"handle":"","piece":"notepat","sub":"","email":""}'; const CONFIG_PAD = 4096; const BOOT_PIECES = ["notepat", "prompt", "chat", "laer-klokken"]; @@ -288,7 +288,7 @@ function boot({ user, handle: getHandle, api, ui, needsPaint }) { buildPollTimer = setInterval(() => fetchBuildStatus(needsPaint), 30000); // Probe CL variant availability (HEAD request) - fetch(ISO_BASE.replace("native-notepat", "cl-native-notepat"), { method: "HEAD" }) + fetch(IMAGE_BASE.replace("native-notepat", "cl-native-notepat"), { method: "HEAD" }) .then(r => { clAvailable = r.ok; console.log("[os] CL variant:", clAvailable ? "available" : "not yet"); needsPaint(); }) .catch(() => { clAvailable = false; }); @@ -674,14 +674,14 @@ function paint($) { sectionHeader("Install", dark ? [14, 20, 32] : [210, 215, 230], C.secInstBg, 120); const instLines = isMobile ? [ - [C.instText, "1 flash .iso (Fedora Media Writer)"], + [C.instText, "1 flash .img (Fedora Media Writer)"], [C.instText, "2 plug USB into x86 PC"], [C.instText, "3 BIOS boot menu:"], [C.instKey, " F12 Dell/Lenovo F9 HP"], [C.instKey, " F2 ASUS/Acer ESC others"], [C.instText, "4 select USB drive"], ] : [ - [C.instText, "1 flash .iso with Fedora Media Writer"], + [C.instText, "1 flash .img with Fedora Media Writer"], [C.instText, "2 plug USB into any x86 PC"], [C.instText, "3 enter BIOS boot menu:"], [C.instKey, " F12 Dell/Lenovo F9 HP"], @@ -991,21 +991,20 @@ async function startDownload(needsPaint) { "&wifi=" + (wifiEnabled ? "1" : "0") + "&cb=" + Date.now() + (variantIdx === 1 ? "&variant=cl" : ""); - const efiQuery = query + "&layout=efi&strict=1"; + const imageQuery = query + "&layout=img&strict=1"; const downloadCandidates = [ { - url: OVEN_BASE() + "/os-image" + query, - allowedLayouts: ["iso"], + url: OVEN_BASE() + "/os-image" + imageQuery, + allowedLayouts: ["img"], rejectOriginFallback: true, }, { - url: OVEN_ORIGIN + "/os-image" + efiQuery, - allowedLayouts: ["efi"], + url: OVEN_ORIGIN + "/os-image" + imageQuery, + allowedLayouts: ["img"], rejectOriginFallback: false, }, ]; - const MIN_EXPECTED_EFI_BYTES = 300 * 1024 * 1024; - const MIN_EXPECTED_ISO_BYTES = 100 * 1024 * 1024; + const MIN_EXPECTED_IMAGE_BYTES = 300 * 1024 * 1024; let res = null; let usedUrl = ""; @@ -1051,12 +1050,7 @@ async function startDownload(needsPaint) { } const len = parseInt(attempt.headers.get("content-length") || "0"); - const minExpectedBytes = - servedLayout === "efi" - ? MIN_EXPECTED_EFI_BYTES - : servedLayout === "iso" - ? MIN_EXPECTED_ISO_BYTES - : MIN_EXPECTED_EFI_BYTES; + const minExpectedBytes = MIN_EXPECTED_IMAGE_BYTES; if (len > 0 && len < minExpectedBytes) { console.warn("[os] Rejecting suspiciously small image response:", len, "bytes from", candidate.url, "layout:", servedLayout || "?"); try { attempt.body?.cancel(); } catch (_) {} @@ -1097,13 +1091,8 @@ async function startDownload(needsPaint) { needsPaint(); const total = chunks.reduce((s, c) => s + c.length, 0); - const servedLayout = (res.headers.get("x-ac-os-layout") || "").toLowerCase(); - const minExpectedBytes = - servedLayout === "efi" - ? MIN_EXPECTED_EFI_BYTES - : servedLayout === "iso" - ? MIN_EXPECTED_ISO_BYTES - : MIN_EXPECTED_EFI_BYTES; + const servedLayout = (res.headers.get("x-ac-os-layout") || "").toLowerCase(); + const minExpectedBytes = MIN_EXPECTED_IMAGE_BYTES; if (total < minExpectedBytes) { throw new Error("Image too small (" + (total / 1048576).toFixed(1) + "MB), refusing to save"); } @@ -1129,8 +1118,8 @@ async function startDownload(needsPaint) { const d = new Date(); const p = (n) => String(n).padStart(2, "0"); const ts = `${d.getFullYear()}.${p(d.getMonth()+1)}.${p(d.getDate())}.${p(d.getHours())}.${p(d.getMinutes())}.${p(d.getSeconds())}`; - const extension = servedLayout === "iso" ? "iso" : "img"; - const mimeType = servedLayout === "iso" ? "application/x-iso9660-image" : "application/octet-stream"; + const extension = "img"; + const mimeType = "application/octet-stream"; const filename = `@${handle || "user"}-os-${piece}-${coreName}-${ts}.${extension}`; console.log("[os] Download complete:", filename, (total / 1048576).toFixed(1) + "MB"); @@ -1157,7 +1146,7 @@ async function startTemplateDownload(needsPaint) { needsPaint(); try { - const res = await fetch(templateIsoUrl()); + const res = await fetch(templateImageUrl()); if (!res.ok) throw new Error("Download failed: " + res.status); const contentLength = parseInt(res.headers.get("content-length") || "0"); @@ -1225,7 +1214,7 @@ async function startTemplateDownload(needsPaint) { const d = new Date(); const p = (n) => String(n).padStart(2, "0"); const ts = `${d.getFullYear()}.${p(d.getMonth()+1)}.${p(d.getDate())}.${p(d.getHours())}.${p(d.getMinutes())}.${p(d.getSeconds())}`; - const filename = `ac-os-${piece}-${coreName}-${ts}.iso`; + const filename = `ac-os-${piece}-${coreName}-${ts}.img`; console.log("[os] Template download complete:", filename, (total / 1048576).toFixed(1) + "MB"); dlFn(filename, combined, { type: "application/octet-stream" }); -- 2.51.2