├── .github ├── FUNDING.yml ├── codecov.yml ├── dependabot.yml ├── images │ ├── build.gif │ ├── goldboot-256.png │ ├── overview.png │ └── select_image.png └── workflows │ ├── check.yml │ ├── prepare_release.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .gitmodules ├── .release-plz.toml ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── README.md ├── UNLICENSE ├── goldboot-image ├── CHANGELOG.md ├── Cargo.toml ├── src │ ├── lib.rs │ └── qcow │ │ ├── header.rs │ │ ├── levels.rs │ │ ├── mod.rs │ │ └── snapshot.rs └── test │ ├── empty.qcow2 │ └── small.qcow2 ├── goldboot-macros ├── CHANGELOG.md ├── Cargo.toml └── src │ └── lib.rs ├── goldboot-registry ├── Cargo.toml ├── Dockerfile ├── README.md └── src │ ├── api │ ├── build.rs │ ├── image.rs │ └── mod.rs │ ├── cmd │ └── mod.rs │ ├── extract.rs │ └── main.rs ├── goldboot ├── CHANGELOG.md ├── Cargo.toml ├── Dockerfile ├── README.md ├── build.rs ├── examples │ └── apply_image_demo.rs ├── flake.lock ├── flake.nix ├── goldboot.json └── src │ ├── builder │ ├── fabricators │ │ ├── ansible.rs │ │ ├── exe.rs │ │ ├── mod.rs │ │ └── shell.rs │ ├── http.rs │ ├── mod.rs │ ├── options │ │ ├── arch.rs │ │ ├── dns_resolver.rs │ │ ├── hostname.rs │ │ ├── iso.rs │ │ ├── locale.rs │ │ ├── luks.rs │ │ ├── mod.rs │ │ ├── size.rs │ │ ├── timezone.rs │ │ └── unix_account.rs │ ├── os │ │ ├── alpine_linux │ │ │ ├── icon.png │ │ │ ├── icon@2x.png │ │ │ └── mod.rs │ │ ├── android │ │ │ └── mod.rs │ │ ├── arch_linux │ │ │ ├── archinstall.rs │ │ │ ├── bootstrap.sh │ │ │ ├── icon.png │ │ │ ├── icon@2x.png │ │ │ └── mod.rs │ │ ├── buildroot │ │ │ └── mod.rs │ │ ├── debian │ │ │ ├── icon.png │ │ │ ├── icon@2x.png │ │ │ ├── mod.rs │ │ │ └── preseed.cfg │ │ ├── fedora │ │ │ ├── icon.png │ │ │ └── icon@2x.png │ │ ├── linux_mint │ │ │ ├── icon.png │ │ │ └── icon@2x.png │ │ ├── mac_os │ │ │ ├── icon.png │ │ │ ├── icon@2x.png │ │ │ └── mod.rs │ │ ├── mod.rs │ │ ├── nix │ │ │ └── mod.rs │ │ ├── open_suse │ │ │ ├── icon.png │ │ │ └── icon@2x.png │ │ ├── pop_os │ │ │ └── mod.rs │ │ ├── slackware │ │ │ ├── icon.png │ │ │ └── icon@2x.png │ │ ├── steam_deck │ │ │ └── mod.rs │ │ ├── steam_os │ │ │ └── mod.rs │ │ ├── ubuntu │ │ │ ├── icon.png │ │ │ ├── icon@2x.png │ │ │ └── mod.rs │ │ ├── windows_10 │ │ │ ├── configure_winrm.ps1 │ │ │ ├── icon.png │ │ │ ├── icon@2x.png │ │ │ └── mod.rs │ │ ├── windows_11 │ │ │ └── mod.rs │ │ └── windows_7 │ │ │ └── mod.rs │ ├── ovmf │ │ ├── aarch64.fd.zst │ │ ├── i386.fd.zst │ │ ├── mod.rs │ │ └── x86_64.fd.zst │ ├── qemu.rs │ ├── sources.rs │ ├── sources │ │ └── buildroot.rs │ ├── ssh.rs │ └── vnc.rs │ ├── cli │ ├── cmd │ │ ├── build.rs │ │ ├── deploy.rs │ │ ├── image.rs │ │ ├── init.rs │ │ ├── liveusb.rs │ │ ├── mod.rs │ │ └── registry.rs │ ├── mod.rs │ ├── progress.rs │ └── prompt.rs │ ├── config.rs │ ├── gbl.rs │ ├── gui │ ├── app.rs │ ├── mod.rs │ ├── resources.rs │ ├── resources │ │ ├── icons │ │ │ ├── hdd.png │ │ │ ├── nvme.png │ │ │ ├── ram.png │ │ │ └── ssd.png │ │ └── logo-512.png │ ├── screens │ │ ├── apply_image.rs │ │ ├── confirm.rs │ │ ├── mod.rs │ │ ├── registry_login.rs │ │ ├── select_device.rs │ │ └── select_image.rs │ ├── state.rs │ ├── theme.rs │ └── widgets │ │ ├── header.rs │ │ ├── hotkeys.rs │ │ └── mod.rs │ ├── lib.rs │ ├── library.rs │ ├── main.rs │ ├── pivot_root.rs │ └── registry │ ├── api │ ├── image.rs │ └── mod.rs │ └── mod.rs ├── rustfmt.toml └── shell.nix /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: cilki 4 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | # ref: https://docs.codecov.com/docs/codecovyml-reference 2 | coverage: 3 | # Hold ourselves to a low bar 4 | range: 50..100 5 | round: down 6 | precision: 1 7 | status: 8 | # ref: https://docs.codecov.com/docs/commit-status 9 | project: 10 | default: 11 | # Avoid false negatives 12 | threshold: 1% 13 | 14 | # Test files aren't important for coverage 15 | ignore: 16 | - "tests" 17 | 18 | # Make comments less noisy 19 | comment: 20 | layout: "files" 21 | require_changes: true 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: "cargo" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/images/build.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/.github/images/build.gif -------------------------------------------------------------------------------- /.github/images/goldboot-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/.github/images/goldboot-256.png -------------------------------------------------------------------------------- /.github/images/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/.github/images/overview.png -------------------------------------------------------------------------------- /.github/images/select_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/.github/images/select_image.png -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: check 2 | permissions: 3 | contents: read 4 | # This configuration allows maintainers of this repo to create a branch and pull request based on 5 | # the new branch. Restricting the push trigger to the main branch ensures that the PR only gets 6 | # built once. 7 | on: 8 | push: 9 | branches: [master] 10 | pull_request: 11 | # If new code is pushed to a PR branch, then cancel in progress workflows for that PR. Ensures that 12 | # we don't waste CI time, and returns results quicker https://github.com/jonhoo/rust-ci-conf/pull/5 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 15 | cancel-in-progress: true 16 | jobs: 17 | fmt: 18 | runs-on: ubuntu-latest 19 | if: "!contains(github.event.head_commit.message, 'chore: release')" 20 | name: stable / fmt 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Install stable 24 | uses: dtolnay/rust-toolchain@stable 25 | with: 26 | components: rustfmt 27 | - name: cargo fmt --check 28 | run: cargo fmt --check 29 | clippy: 30 | runs-on: ubuntu-latest 31 | if: "!contains(github.event.head_commit.message, 'chore: release')" 32 | name: ${{ matrix.toolchain }} / clippy 33 | permissions: 34 | contents: read 35 | checks: write 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | # Get early warning of new lints which are regularly introduced in beta channels. 40 | toolchain: [stable, beta] 41 | steps: 42 | - uses: actions/checkout@v4 43 | - name: Install ${{ matrix.toolchain }} 44 | uses: dtolnay/rust-toolchain@master 45 | with: 46 | toolchain: ${{ matrix.toolchain }} 47 | components: clippy 48 | - name: cargo clippy 49 | uses: giraffate/clippy-action@v1 50 | with: 51 | reporter: "github-pr-check" 52 | github_token: ${{ secrets.GITHUB_TOKEN }} 53 | doc: 54 | runs-on: ubuntu-24.04 55 | if: "!contains(github.event.head_commit.message, 'chore: release')" 56 | name: nightly / doc 57 | steps: 58 | - run: sudo apt-get update && sudo apt-get install -y libpango1.0-dev libgraphene-1.0-dev libudev-dev libgtk-4-dev libglib2.0-dev 59 | - uses: actions/checkout@v4 60 | - name: Install nightly 61 | uses: dtolnay/rust-toolchain@nightly 62 | - name: cargo doc 63 | run: cargo doc --no-deps --all-features 64 | env: 65 | RUSTDOCFLAGS: --cfg docsrs 66 | msrv: 67 | runs-on: ubuntu-24.04 68 | if: "!contains(github.event.head_commit.message, 'chore: release')" 69 | strategy: 70 | matrix: 71 | msrv: ["1.74.0"] 72 | name: ubuntu / ${{ matrix.msrv }} 73 | steps: 74 | - run: sudo apt-get update && sudo apt-get install -y libpango1.0-dev libgraphene-1.0-dev libudev-dev libgtk-4-dev libglib2.0-dev 75 | - uses: actions/checkout@v4 76 | - name: Install ${{ matrix.msrv }} 77 | uses: dtolnay/rust-toolchain@master 78 | with: 79 | toolchain: ${{ matrix.msrv }} 80 | - name: cargo +${{ matrix.msrv }} check 81 | run: cargo check 82 | -------------------------------------------------------------------------------- /.github/workflows/prepare_release.yml: -------------------------------------------------------------------------------- 1 | name: prepare_release 2 | permissions: 3 | pull-requests: write 4 | contents: write 5 | on: 6 | push: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | github: 12 | runs-on: ubuntu-latest 13 | if: "!contains(github.event.head_commit.message, 'chore: release')" 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - uses: dtolnay/rust-toolchain@stable 20 | 21 | - uses: MarcoIeni/release-plz-action@v0.5 22 | with: 23 | command: release-pr 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | permissions: 3 | contents: read 4 | on: 5 | push: 6 | branches: [master] 7 | pull_request: 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 10 | cancel-in-progress: true 11 | jobs: 12 | required: 13 | runs-on: ubuntu-24.04 14 | if: "!contains(github.event.head_commit.message, 'chore: release')" 15 | name: ubuntu / ${{ matrix.toolchain }} 16 | strategy: 17 | matrix: 18 | # run on stable and beta to ensure that tests won't break on the next version of the rust 19 | # toolchain 20 | toolchain: [stable, beta] 21 | steps: 22 | - run: sudo apt-get update && sudo apt-get install -y qemu-utils libpango1.0-dev libgraphene-1.0-dev libudev-dev libgtk-4-dev libglib2.0-dev 23 | - uses: actions/checkout@v4 24 | - name: Install ${{ matrix.toolchain }} 25 | uses: dtolnay/rust-toolchain@master 26 | with: 27 | toolchain: ${{ matrix.toolchain }} 28 | - name: cargo generate-lockfile 29 | # enable this ci template to run regardless of whether the lockfile is checked in or not 30 | if: hashFiles('Cargo.lock') == '' 31 | run: cargo generate-lockfile 32 | # https://twitter.com/jonhoo/status/1571290371124260865 33 | - name: cargo test --locked 34 | run: cargo test --locked --all-features 35 | # https://github.com/rust-lang/cargo/issues/6669 36 | - name: cargo test --doc 37 | run: cargo test --locked --all-features --doc 38 | os-check: 39 | # run cargo test on mac and windows 40 | runs-on: ${{ matrix.os }} 41 | if: "!contains(github.event.head_commit.message, 'chore: release')" 42 | name: ${{ matrix.os }} / stable 43 | strategy: 44 | fail-fast: false 45 | matrix: 46 | os: [macos-latest, windows-latest] 47 | steps: 48 | - uses: actions/checkout@v4 49 | - name: Install stable 50 | uses: dtolnay/rust-toolchain@stable 51 | - name: cargo generate-lockfile 52 | if: hashFiles('Cargo.lock') == '' 53 | run: cargo generate-lockfile 54 | - name: cargo test 55 | run: cargo test --locked --all-features --all-targets --workspace --exclude goldboot-uki 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | screenshots 3 | result 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.release-plz.toml: -------------------------------------------------------------------------------- 1 | [changelog] 2 | header = "# Changelog" 3 | body = "Body" 4 | trim = true 5 | protect_breaking_commits = true 6 | sort_commits = "newest" 7 | 8 | commit_parsers = [ 9 | { message = "^.*: add", group = "Added" }, 10 | { message = "^.*: support", group = "Added" }, 11 | { message = "^.*: remove", group = "Removed" }, 12 | { message = "^.*: delete", group = "Removed" }, 13 | { message = "^test", group = "Fixed" }, 14 | { message = "^fix", group = "Fixed" }, 15 | { message = "^.*: fix", group = "Fixed" }, 16 | ] 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This guide contains some information for first time contributors. 4 | 5 | ## Architecture 6 | 7 | There are currently four crates to know about: 8 | 9 | - `goldboot` 10 | - The primary CLI application for building images 11 | - `goldboot-image` 12 | - Implements the goldboot image format 13 | - `goldboot-macros` 14 | - Procedural macros to simplify implementation of the `goldboot` crate 15 | - `goldboot-registry` 16 | - Web service that hosts goldboot images 17 | 18 | ## The metallurgy metaphor 19 | 20 | Although end-users could mostly ignore it, the internals of `goldboot` use 21 | vocabulary appropriated from the field of metallurgy. 22 | 23 | ### Element 24 | 25 | An image element takes an image source and refines it according to built-in 26 | rules. 27 | 28 | For example, the `ArchLinux` element knows how to take Arch Linux install media 29 | (in the form of an ISO) and install it in an automated manner. 30 | 31 | ### Building 32 | 33 | Building is the process that takes image sources and produces a final goldboot 34 | image containing all customizations. 35 | 36 | Under the hood, foundries cast images by spawning a Qemu virtual machine and 37 | running one or more image molds against it (via SSH or VNC). Once the virtual 38 | machine is fully provisioned, its shutdown and the underlying storage (.qcow2) 39 | is converted into a final goldboot image (.gb). 40 | 41 | ### Alloys 42 | 43 | An alloy is a multi-boot image that consists of more than one element. 44 | 45 | ### Fabricators 46 | 47 | Operates on images at the end of the casting process. For example, the shell 48 | fabricator runs shell commands on the image which can be useful in many cases. 49 | 50 | ### Casting 51 | 52 | If we followed the metaphor strictly, then _casting_ would be a synonym for 53 | _building_, but we decided instead to make it be the process of applying an 54 | image to a device. 55 | 56 | ## Supporting new operating systems 57 | 58 | If `goldboot` doesn't already support your operating system, it should be 59 | possible to add it relatively easily. 60 | 61 | Start by finding an OS similar to yours in the `goldboot::builder::os` module. 62 | 63 | TODO 64 | 65 | ## OS maintenance 66 | 67 | OS support often need constant maintenance as new upstream releases are made and 68 | old ones are retired. Typically this involves adding new versions and marking 69 | some as deprecated, but occasionally upstream changes may cause breakages for 70 | us. 71 | 72 | For example, we have a struct that tracks Alpine releases which needs to be 73 | updated about twice per year: 74 | 75 | ```rust 76 | #[derive(Clone, Serialize, Deserialize, Debug, EnumIter)] 77 | pub enum AlpineRelease { 78 | Edge, 79 | #[serde(rename = "v3.17")] 80 | V3_17, 81 | #[serde(rename = "v3.16")] 82 | V3_16, 83 | #[serde(rename = "v3.15")] 84 | V3_15, 85 | #[deprecated] 86 | #[serde(rename = "v3.14")] 87 | V3_14, 88 | } 89 | ``` 90 | 91 | ## Testing 92 | 93 | TODO 94 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [profile.release] 2 | strip = true 3 | 4 | [workspace] 5 | members = [ 6 | "goldboot", 7 | "goldboot-image", 8 | "goldboot-macros", 9 | "goldboot-registry", 10 | ] 11 | 12 | [workspace.dependencies] 13 | anyhow = "1.0.76" 14 | fossable = "0.1.2" 15 | hex = "0.4.3" 16 | rand = "0.9.0" 17 | regex = "1.10.2" 18 | serde = { version = "1.0.192", features = ["derive"] } 19 | sha2 = "0.10.8" 20 | strum = { version = "0.27.1", features = ["derive"] } 21 | tracing = "0.1.40" 22 | zstd = "0.13.0" 23 | reqwest = "0.12.15" 24 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /goldboot-image/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | -------------------------------------------------------------------------------- /goldboot-image/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Tyler Cook"] 3 | description = "Defines the goldboot image format" 4 | edition = "2024" 5 | homepage = "https://goldboot.fossable.org" 6 | license = "Unlicense" 7 | name = "goldboot-image" 8 | repository = "https://github.com/fossable/goldboot" 9 | rust-version = "1.85" 10 | version = "0.0.5" 11 | 12 | [dependencies] 13 | aes-gcm = { version = "0.10.3", features = ["std"] } 14 | anyhow = { workspace = true } 15 | binrw = "0.15.0" 16 | flate2 = "1.0.28" 17 | hex = { workspace = true } 18 | rand = { workspace = true } 19 | regex = { workspace = true } 20 | serde = { workspace = true } 21 | sha2 = { workspace = true } 22 | strum = { workspace = true } 23 | tracing = { workspace = true } 24 | zstd = { workspace = true } 25 | 26 | [dev-dependencies] 27 | sha1 = "0.10.6" 28 | tempfile = "3.8.1" 29 | test-log = { version = "0.2.16", features = ["trace"] } 30 | -------------------------------------------------------------------------------- /goldboot-image/src/qcow/header.rs: -------------------------------------------------------------------------------- 1 | use binrw::BinRead; 2 | 3 | /// Qcow header version 3. 4 | #[derive(BinRead, Debug)] 5 | #[br(magic = b"QFI\xfb")] 6 | pub struct QcowHeader { 7 | /// Version of the QCOW format. 8 | #[br(assert(version == 3))] 9 | pub version: u32, 10 | 11 | /// Offset into the image file at which the backing file name 12 | /// is stored (NB: The string is not null terminated). 0 if the 13 | /// image doesn't have a backing file. 14 | _backing_file_offset: u64, 15 | 16 | /// Length of the backing file name in bytes. Must not be 17 | /// longer than 1023 bytes. Undefined if the image doesn't have 18 | /// a backing file. 19 | _backing_file_size: u32, 20 | 21 | /// Number of bits that are used for addressing an offset 22 | /// within a cluster (1 << cluster_bits is the cluster size). 23 | /// Must not be less than 9 (i.e. 512 byte clusters). 24 | pub cluster_bits: u32, 25 | 26 | /// Virtual disk size in bytes. 27 | pub size: u64, 28 | 29 | /// Encryption method to use for contents 30 | _crypt_method: u32, 31 | 32 | /// Number of entries in the active L1 table 33 | pub l1_size: u32, 34 | 35 | /// Offset into the image file at which the active L1 table 36 | /// starts. Must be aligned to a cluster boundary. 37 | pub l1_table_offset: u64, 38 | 39 | /// Offset into the image file at which the refcount table 40 | /// starts. Must be aligned to a cluster boundary. 41 | _refcount_table_offset: u64, 42 | 43 | /// Number of clusters that the refcount table occupies 44 | _refcount_table_clusters: u32, 45 | 46 | /// Number of snapshots contained in the image 47 | pub nb_snapshots: u32, 48 | 49 | /// Offset into the image file at which the snapshot table 50 | /// starts. Must be aligned to a cluster boundary. 51 | pub snapshots_offset: u64, 52 | 53 | /// Bitmask of incompatible features. An implementation must fail to open an 54 | /// image if an unknown bit is set. 55 | #[br(align_after = 8)] 56 | _incompatible_features: u64, 57 | 58 | /// Bitmask of compatible features. An implementation can safely ignore any 59 | /// unknown bits that are set. 60 | _compatible_features: u64, 61 | 62 | /// Bitmask of auto-clear features. An implementation may only write to an 63 | /// image with unknown auto-clear features if it clears the respective bits 64 | /// from this field first. 65 | _autoclear_features: u64, 66 | 67 | /// Describes the width of a reference count block entry (width 68 | /// in bits: refcount_bits = 1 << refcount_order). For version 2 69 | /// images, the order is always assumed to be 4 70 | /// (i.e. refcount_bits = 16). 71 | /// This value may not exceed 6 (i.e. refcount_bits = 64). 72 | _refcount_order: u32, 73 | 74 | /// Total length of the header. 75 | pub header_len: u32, 76 | 77 | /// Defines the compression method used for compressed clusters. 78 | /// 79 | /// All compressed clusters in an image use the same compression 80 | /// type. 81 | /// 82 | /// If the incompatible bit "Compression type" is set: the field 83 | /// must be present and non-zero (which means non-zlib 84 | /// compression type). Otherwise, this field must not be present 85 | /// or must be zero (which means zlib). 86 | #[br(if(header_len > 104))] 87 | pub compression_type: CompressionType, 88 | 89 | /// Marks the end of the extensions 90 | _end: u32, 91 | } 92 | 93 | /// Compression type used for compressed clusters. 94 | #[derive(BinRead, Debug, Clone, Copy, PartialEq, Eq, Default)] 95 | #[br(repr(u8))] 96 | pub enum CompressionType { 97 | /// Uses flate/zlib compression for any clusters which are compressed 98 | #[default] 99 | Zlib = 0, 100 | 101 | /// Uses zstandard compression for any clusters which are compressed 102 | Zstd = 1, 103 | } 104 | 105 | impl QcowHeader { 106 | /// Get the size of a cluster in bytes from the qcow 107 | pub fn cluster_size(&self) -> u64 { 108 | 1 << self.cluster_bits 109 | } 110 | 111 | /// Get the number of entries in an L2 table. 112 | pub fn l2_entries_per_cluster(&self) -> u64 { 113 | self.cluster_size() / 8 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /goldboot-image/src/qcow/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Result, bail}; 2 | use binrw::{BinRead, BinReaderExt, io::SeekFrom}; 3 | use snapshot::Snapshot; 4 | use std::{ 5 | fs::File, 6 | io::BufReader, 7 | path::Path, 8 | process::{Command, Stdio}, 9 | }; 10 | use tracing::debug; 11 | 12 | mod header; 13 | pub use header::*; 14 | 15 | pub mod levels; 16 | use levels::*; 17 | 18 | mod snapshot; 19 | 20 | /// Represents a (stripped down) qcow3 file on disk. 21 | #[derive(BinRead, Debug)] 22 | #[brw(big)] 23 | pub struct Qcow3 { 24 | /// The image header 25 | pub header: QcowHeader, 26 | 27 | /// List of snapshots present within this qcow 28 | #[br(seek_before = SeekFrom::Start(header.snapshots_offset), count = header.nb_snapshots)] 29 | pub snapshots: Vec, 30 | 31 | /// The "active" L1 table 32 | #[br(seek_before = SeekFrom::Start(header.l1_table_offset), count = header.l1_size)] 33 | pub l1_table: Vec, 34 | 35 | /// The file path 36 | #[br(ignore)] 37 | pub path: String, 38 | } 39 | 40 | impl Qcow3 { 41 | /// Open a qcow3 file from the given path. 42 | pub fn open(path: impl AsRef) -> Result { 43 | let mut file = BufReader::new(File::open(&path)?); 44 | 45 | let mut qcow: Qcow3 = file.read_be()?; 46 | qcow.path = path.as_ref().to_string_lossy().to_string(); 47 | 48 | debug!(qcow = ?qcow, "Opened qcow image"); 49 | Ok(qcow) 50 | } 51 | 52 | /// Allocate a new qcow3 file. 53 | pub fn create(path: impl AsRef, size: u64) -> Result { 54 | let path = path.as_ref(); 55 | 56 | // If we don't pass an image size that's a power of two, qemu-img will 57 | // silently round up which is bad. 58 | assert!(size % 2 == 0, "The image size must be a power of 2"); 59 | 60 | debug!(path = ?path, "Creating qcow storage"); 61 | let status = Command::new("qemu-img") 62 | .args([ 63 | "create", 64 | "-f", 65 | "qcow2", 66 | "-o", 67 | "cluster_size=65536", 68 | &path.to_string_lossy().to_string(), 69 | &format!("{size}"), 70 | ]) 71 | .stdout(Stdio::null()) 72 | .stderr(Stdio::null()) 73 | .status() 74 | .unwrap(); 75 | 76 | if status.code().unwrap() != 0 { 77 | bail!("Failed to allocate image with qemu-img"); 78 | } 79 | 80 | Qcow3::open(path) 81 | } 82 | 83 | /// Count the number of allocated clusters. 84 | pub fn count_clusters(&self) -> Result { 85 | let mut count = 0; 86 | 87 | for l1_entry in &self.l1_table { 88 | if let Some(l2_table) = 89 | l1_entry.read_l2(&mut File::open(&self.path)?, self.header.cluster_bits) 90 | { 91 | for l2_entry in l2_table { 92 | if l2_entry.is_used { 93 | count += 1; 94 | } 95 | } 96 | } 97 | } 98 | Ok(count) 99 | } 100 | } 101 | 102 | #[cfg(test)] 103 | mod tests { 104 | use super::*; 105 | 106 | #[test] 107 | fn test_open() -> Result<()> { 108 | let qcow = Qcow3::open("test/empty.qcow2")?; 109 | assert_eq!(qcow.header.cluster_bits, 16); 110 | assert_eq!(qcow.header.cluster_size(), 65536); 111 | Ok(()) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /goldboot-image/src/qcow/snapshot.rs: -------------------------------------------------------------------------------- 1 | use super::levels::L1Entry; 2 | use binrw::{BinRead, binread, io::SeekFrom}; 3 | 4 | /// An entry in the snapshot table representing the system state at a moment in 5 | /// time 6 | #[binread] 7 | #[derive(Debug)] 8 | pub struct Snapshot { 9 | /// Offset into the image file at which the L1 table for the 10 | /// snapshot starts. Must be aligned to a cluster boundary. 11 | #[br(temp)] 12 | l1_table_offset: u64, 13 | 14 | /// Number of entries in the L1 table of the snapshots 15 | #[br(temp)] 16 | l1_entry_count: u32, 17 | 18 | /// Table of L1 entries in the screenshot 19 | #[br(restore_position, seek_before = SeekFrom::Start(l1_table_offset), count = l1_entry_count)] 20 | pub l1_table: Vec, 21 | 22 | /// Length of the unique ID string describing the snapshot 23 | #[br(temp)] 24 | unique_id_len: u16, 25 | 26 | /// Length of the name of the snapshot 27 | #[br(temp)] 28 | name_len: u16, 29 | 30 | /// Time at which the snapshot was taken since the Epoch 31 | pub time: SnapshotTime, 32 | 33 | /// Time that the guest was running until the snapshot was taken in 34 | /// nanoseconds 35 | pub guest_runtime: u64, 36 | 37 | /// Size of the VM state in bytes. 0 if no VM state is saved. 38 | /// 39 | /// If there is VM state, it starts at the first cluster 40 | /// described by first L1 table entry that doesn't describe a 41 | /// regular guest cluster (i.e. VM state is stored like guest 42 | /// disk content, except that it is stored at offsets that are 43 | /// larger than the virtual disk presented to the guest) 44 | pub vm_state_size: u32, 45 | 46 | #[br(temp)] 47 | extra_data_size: u32, 48 | 49 | /// Optional extra snapshot data that comes from format updates 50 | #[br(pad_size_to = extra_data_size)] 51 | #[br(args(extra_data_size))] 52 | pub extra_data: SnapshotExtraData, 53 | 54 | /// A unique identifier for the snapshot (example value: "1") 55 | #[br(count = unique_id_len, try_map = String::from_utf8)] 56 | pub unique_id: String, 57 | 58 | /// Name of the snapshot 59 | #[br(count = name_len, try_map = String::from_utf8)] 60 | pub name: String, 61 | } 62 | 63 | /// Optional extra snapshot data that comes from format updates 64 | /// 65 | /// **Note:** Version 3 snapshots must have both vm_state_size and 66 | /// virtual_disk_size present. 67 | #[derive(BinRead, Debug)] 68 | #[br(import(size: u32))] 69 | pub struct SnapshotExtraData { 70 | /// Size of the VM state in bytes. 0 if no VM state is saved. If this field 71 | /// is present, the 32-bit value in Snapshot.vm_state_size is ignored. 72 | #[br(if(size >= 8))] 73 | pub vm_state_size: u64, 74 | 75 | /// Virtual disk size of the snapshot in bytes 76 | #[br(if(size >= 16))] 77 | pub virtual_disk_size: Option, 78 | 79 | /// icount value which corresponds to the record/replay instruction count 80 | /// when the snapshot was taken. Set to -1 if icount was disabled 81 | #[br(if(size >= 24))] 82 | pub instruction_count: Option, 83 | } 84 | 85 | /// Represents the time a snapshot was taken in the form of seconds, 86 | /// nanoseconds. The nanoseconds represent the sub-second time of the snapshot. 87 | #[derive(BinRead, Debug)] 88 | pub struct SnapshotTime { 89 | /// Seconds since the unix epoch 90 | pub secs: u32, 91 | 92 | /// Subsecond portion of time in nanoseconds 93 | pub nanosecs: u32, 94 | } 95 | -------------------------------------------------------------------------------- /goldboot-image/test/empty.qcow2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot-image/test/empty.qcow2 -------------------------------------------------------------------------------- /goldboot-image/test/small.qcow2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot-image/test/small.qcow2 -------------------------------------------------------------------------------- /goldboot-macros/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog -------------------------------------------------------------------------------- /goldboot-macros/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Tyler Cook"] 3 | description = "Supporting macros for goldboot" 4 | edition = "2024" 5 | license = "Unlicense" 6 | name = "goldboot-macros" 7 | rust-version = "1.85" 8 | version = "0.0.3" 9 | repository = "https://github.com/fossable/goldboot" 10 | 11 | [lib] 12 | proc-macro = true 13 | 14 | [dependencies] 15 | quote = "1.0.33" 16 | syn = "2.0.39" 17 | -------------------------------------------------------------------------------- /goldboot-macros/src/lib.rs: -------------------------------------------------------------------------------- 1 | use proc_macro::TokenStream; 2 | use quote::quote; 3 | use syn::{self}; 4 | 5 | /// Automatically implement "Prompt" for all fields in a struct. 6 | #[proc_macro_derive(Prompt)] 7 | pub fn prompt(input: TokenStream) -> TokenStream { 8 | let ast = syn::parse(input).unwrap(); 9 | impl_prompt(&ast) 10 | } 11 | 12 | /// Automatically implement the "size()" method from BuildImage trait. 13 | /// This assumes the struct has a field named "size" of type Size. 14 | #[proc_macro_derive(BuildImageSize)] 15 | pub fn build_image_size(input: TokenStream) -> TokenStream { 16 | let ast = syn::parse(input).unwrap(); 17 | impl_build_image_size(&ast) 18 | } 19 | 20 | fn impl_prompt(ast: &syn::DeriveInput) -> TokenStream { 21 | let name = &ast.ident; 22 | 23 | let fields_to_prompt: Vec<_> = match &ast.data { 24 | syn::Data::Struct(data) => match &data.fields { 25 | syn::Fields::Named(fields) => fields 26 | .named 27 | .iter() 28 | .filter_map(|f| { 29 | // Skip fields with #[serde(flatten)] 30 | let is_flattened = f.attrs.iter().any(|attr| { 31 | attr.path().is_ident("serde") 32 | && attr 33 | .parse_args::() 34 | .map(|i| i == "flatten") 35 | .unwrap_or(false) 36 | }); 37 | 38 | if is_flattened { 39 | None 40 | } else { 41 | Some((f.ident.clone().unwrap(), f.ty.clone())) 42 | } 43 | }) 44 | .collect(), 45 | _ => panic!("Prompt derive only works on structs with named fields"), 46 | }, 47 | _ => panic!("Prompt derive only works on structs"), 48 | }; 49 | 50 | let prompt_calls = fields_to_prompt.iter().map(|(field, ty)| { 51 | // Check if the type is an Option 52 | if is_option_type(ty) { 53 | quote! { 54 | if let Some(ref mut value) = self.#field { 55 | value.prompt(builder)?; 56 | } 57 | } 58 | } else { 59 | quote! { 60 | self.#field.prompt(builder)?; 61 | } 62 | } 63 | }); 64 | 65 | let syntax = quote! { 66 | impl crate::cli::prompt::Prompt for #name { 67 | fn prompt( 68 | &mut self, 69 | builder: &crate::builder::Builder, 70 | ) -> anyhow::Result<()> { 71 | #(#prompt_calls)* 72 | Ok(()) 73 | } 74 | } 75 | }; 76 | syntax.into() 77 | } 78 | 79 | fn is_option_type(ty: &syn::Type) -> bool { 80 | if let syn::Type::Path(type_path) = ty { 81 | if let Some(segment) = type_path.path.segments.last() { 82 | return segment.ident == "Option"; 83 | } 84 | } 85 | false 86 | } 87 | 88 | fn impl_build_image_size(ast: &syn::DeriveInput) -> TokenStream { 89 | let name = &ast.ident; 90 | 91 | // Verify that the struct has a field named "size" 92 | let has_size_field = match &ast.data { 93 | syn::Data::Struct(data) => match &data.fields { 94 | syn::Fields::Named(fields) => fields 95 | .named 96 | .iter() 97 | .any(|f| f.ident.as_ref().map(|i| i == "size").unwrap_or(false)), 98 | _ => false, 99 | }, 100 | _ => false, 101 | }; 102 | 103 | if !has_size_field { 104 | panic!("BuildImageSize derive requires a field named 'size'"); 105 | } 106 | 107 | let syntax = quote! { 108 | impl BuildImage for #name { 109 | fn size(&self) -> &crate::builder::options::size::Size { 110 | &self.size 111 | } 112 | } 113 | }; 114 | syntax.into() 115 | } 116 | 117 | // TODO add pyclass 118 | -------------------------------------------------------------------------------- /goldboot-registry/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "goldboot-registry" 3 | description = "A web service for hosting goldboot images" 4 | version = "0.0.5" 5 | edition = "2024" 6 | license = "Unlicense" 7 | authors = ["Tyler Cook"] 8 | readme = "README.md" 9 | homepage = "https://goldboot.org" 10 | repository = "https://github.com/fossable/goldboot" 11 | rust-version = "1.85" 12 | 13 | [dependencies] 14 | anyhow = "1.0.76" 15 | axum = "0.8.3" 16 | clap = { version = "4.4.7", features = ["derive", "string"] } 17 | goldboot-image = { path = "../goldboot-image", version = "0.0.5" } 18 | goldboot = { path = "../goldboot", version = "0.0.10" } 19 | reqwest = { workspace = true, features = ["stream"] } 20 | tftpd = { version = "0.5.0", optional = true } 21 | tokio = { version = "1.34.0", features = ["full"] } 22 | tracing = "0.1.40" 23 | tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } 24 | 25 | [features] 26 | pxe = ["dep:tftpd"] 27 | -------------------------------------------------------------------------------- /goldboot-registry/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | ARG TARGETOS 4 | ARG TARGETARCH 5 | ARG TARGETVARIANT 6 | 7 | RUN apk add --no-cache qemu-system-aarch64 qemu-system-arm qemu-system-x86_64 8 | 9 | COPY ${TARGETOS}-${TARGETARCH}${TARGETVARIANT}/goldboot-registry /usr/bin/goldboot-registry 10 | RUN chmod +x /usr/bin/goldboot-registry 11 | 12 | WORKDIR /root 13 | ENTRYPOINT ["/usr/bin/goldboot-registry"] 14 | -------------------------------------------------------------------------------- /goldboot-registry/README.md: -------------------------------------------------------------------------------- 1 | ## goldboot-registry 2 | 3 | Contains the web application that manages goldboot images. 4 | -------------------------------------------------------------------------------- /goldboot-registry/src/api/build.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::Path; 2 | 3 | /// Start a new build 4 | pub async fn start() {} 5 | 6 | /// List all builds 7 | pub async fn list() {} 8 | 9 | /// Get build info 10 | pub async fn info(Path(_id): Path) {} 11 | 12 | /// Cancel a build 13 | pub async fn cancel(Path(_id): Path) {} 14 | -------------------------------------------------------------------------------- /goldboot-registry/src/api/image.rs: -------------------------------------------------------------------------------- 1 | use crate::extract::ImageHandle; 2 | use axum::{ 3 | Json, 4 | extract::{Path, State}, 5 | http::StatusCode, 6 | }; 7 | use goldboot::registry::api::image::ImageInfoResponse; 8 | 9 | /// Get image info 10 | pub async fn info(image: ImageHandle) -> Json { 11 | Json(image.0.into()) 12 | } 13 | 14 | /// Get image list 15 | pub async fn list() {} 16 | 17 | // Push an image 18 | /* 19 | pub async fn push(id: web::Path, rq: actix_web::HttpRequest) -> Result { 20 | let path = match ImageLibrary::find_by_id(&id) { 21 | Ok(image) => { 22 | // Delete if the image already exists 23 | if Path::new(&image.path).exists() { 24 | std::fs::remove_file(&image.path)?; 25 | } 26 | image.path 27 | }, 28 | _ => format!("{}.gb", id), 29 | }; 30 | 31 | let mut file = File::create(&path)?; 32 | std::io::copy(&mut rq, &mut file)?; 33 | "" 34 | }*/ 35 | 36 | /// Get cluster data 37 | pub async fn clusters(Path(_id): Path, Path(_range): Path) {} 38 | 39 | /// Get cluster hashes 40 | pub async fn hashes(Path(_id): Path, Path(_range): Path) {} 41 | -------------------------------------------------------------------------------- /goldboot-registry/src/api/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod build; 2 | pub mod image; 3 | -------------------------------------------------------------------------------- /goldboot-registry/src/cmd/mod.rs: -------------------------------------------------------------------------------- 1 | #[derive(clap::Subcommand, Debug)] 2 | pub enum Commands { 3 | /// Run the registry server 4 | Start {}, 5 | } 6 | -------------------------------------------------------------------------------- /goldboot-registry/src/extract.rs: -------------------------------------------------------------------------------- 1 | use super::RegistryState; 2 | use axum::{ 3 | extract::{FromRequest, Path, Request}, 4 | http::StatusCode, 5 | }; 6 | use goldboot::library::ImageLibrary; 7 | use std::collections::HashMap; 8 | use tracing::error; 9 | 10 | /// Newtype wrapper for ImageHandle. 11 | pub struct ImageHandle(pub goldboot_image::ImageHandle); 12 | 13 | impl FromRequest for ImageHandle { 14 | type Rejection = StatusCode; 15 | 16 | async fn from_request(req: Request, state: &RegistryState) -> Result { 17 | match Path::>::from_request(req, state).await { 18 | Ok(value) => match value.get("image_id") { 19 | Some(image_id) => match ImageLibrary::find_by_id(image_id) { 20 | Ok(image_handle) => { 21 | // TODO access control 22 | Ok(ImageHandle(image_handle)) 23 | } 24 | Err(_) => Err(StatusCode::NOT_FOUND), 25 | }, 26 | None => { 27 | error!("No image id"); 28 | Err(StatusCode::INTERNAL_SERVER_ERROR) 29 | } 30 | }, 31 | Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /goldboot-registry/src/main.rs: -------------------------------------------------------------------------------- 1 | use crate::cmd::Commands; 2 | use axum::{Router, routing::get}; 3 | use clap::Parser; 4 | use std::{env, process::ExitCode}; 5 | 6 | pub mod api; 7 | pub mod cmd; 8 | pub mod extract; 9 | 10 | #[derive(Parser, Debug)] 11 | #[clap(author, version, about, long_about = None)] 12 | struct CommandLine { 13 | #[clap(subcommand)] 14 | command: Option, 15 | } 16 | 17 | #[derive(Clone)] 18 | pub struct RegistryState {} 19 | 20 | #[tokio::main] 21 | async fn main() { 22 | let command_line = CommandLine::parse(); 23 | tracing_subscriber::fmt() 24 | .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) 25 | .init(); 26 | 27 | let state = RegistryState {}; 28 | 29 | let app = Router::new() 30 | .route("/image/list", get(api::image::list)) 31 | .route("/image/info/:image_id", get(api::image::info)) 32 | .with_state(state); 33 | 34 | let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); 35 | axum::serve(listener, app).await.unwrap(); 36 | } 37 | -------------------------------------------------------------------------------- /goldboot/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | -------------------------------------------------------------------------------- /goldboot/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Tyler Cook"] 3 | description = "A command-line application for building goldboot images" 4 | edition = "2024" 5 | homepage = "https://goldboot.org" 6 | license = "Unlicense" 7 | name = "goldboot" 8 | readme = "README.md" 9 | repository = "https://github.com/fossable/goldboot" 10 | rust-version = "1.85" 11 | version = "0.0.10" 12 | 13 | [dependencies] 14 | anyhow = { workspace = true } 15 | # It seems the next LTS will have a clang version new enough for bindgen 16 | # aws-lc-rs = { version = "1", features = ["bindgen"]} 17 | axum = { version = "0.8.1", optional = true } 18 | block-utils = { version = "0.11.1", optional = true } 19 | built = { version = "0.8.0", features = ["chrono", "semver"] } 20 | byte-unit = "5.1.2" 21 | chrono = "0.4.31" 22 | clap = { version = "4.4.7", features = ["derive", "string"] } 23 | console = "0.16.1" 24 | dialoguer = "0.12.0" 25 | eframe = { version = "0.29", optional = true, default-features = false, features = ["default_fonts", "glow", "persistence"] } 26 | egui = { version = "0.29", optional = true } 27 | enum_dispatch = "0.3.12" 28 | fatfs = { version = "0.3.6", optional = true } 29 | flate2 = "1.0.28" 30 | fossable = { workspace = true } 31 | fscommon = { version = "0.1.1", optional = true } 32 | goldboot-image = { path = "../goldboot-image", version = "0.0.5" } 33 | goldboot-macros = { path = "../goldboot-macros", version = "0.0.3" } 34 | hex = { workspace = true } 35 | image = { version = "0.25", optional = true, default-features = false, features = ["png"] } 36 | indicatif = "0.18.0" 37 | png = { version = "0.18.0", optional = true } 38 | poll-promise = { version = "0.3", optional = true } 39 | pyo3 = { version = "0.26.0", features = ["anyhow"], optional = true } 40 | pythonize = { version = "0.26.0", optional = true } 41 | quick-xml = { version = "0.38.3", features = ["serialize"] } 42 | rand = { workspace = true } 43 | regex = { workspace = true } 44 | reqwest = { workspace = true, features = ["stream", "blocking", "json"] } 45 | ron = { version = "0.11.0", optional = true } 46 | rustls = { version = "0.23.23" } 47 | serde_json = { version = "1.0.108", optional = true } 48 | serde = { workspace = true } 49 | serde_win_unattend = { version = "0.3.3", optional = true } 50 | smart-default = "0.7.1" 51 | serde_yaml = { version = "0.9.27", optional = true } 52 | sha1 = "0.10.6" 53 | sha2 = { workspace = true } 54 | # TODO russh 55 | ssh2 = { version = "0.9.4" } 56 | ssh-key = { version = "0.7.0-pre.1", features = ["ed25519"] } 57 | strum = { workspace = true } 58 | tar = "0.4.40" 59 | tempfile = "3.10.0" 60 | tokio = { version = "1.36.0", features = ["full"] } 61 | toml = { version = "0.9.7", optional = true } 62 | tower-http = { version = "0.6.6", features = ["fs", "trace"] } 63 | tracing = { workspace = true } 64 | tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } 65 | ubyte = "0.10.4" 66 | url = { version = "2.4.1", features = ["serde"] } 67 | uuid = { version = "1.7.0", features = ["v4"] } 68 | validator = { version = "0.20.0", features = ["derive"] } 69 | vnc = { version = "0.4.0", optional = true } 70 | whoami = "1.4.1" 71 | zstd = { workspace = true } 72 | 73 | [dev-dependencies] 74 | 75 | [build-dependencies] 76 | built = { version = "0.8.0", features = [ 77 | "cargo-lock", 78 | "dependency-tree", 79 | "git2", 80 | "chrono", 81 | "semver", 82 | ] } 83 | 84 | [features] 85 | default = [ 86 | "build", 87 | "include_ovmf", 88 | "config-json", 89 | "config-yaml", 90 | "config-ron", 91 | "config-toml", 92 | "config-python", 93 | ] 94 | 95 | # Support for registry server 96 | registry = [] 97 | 98 | # Support for building images 99 | build = [ 100 | "dep:fatfs", 101 | "dep:fscommon", 102 | "dep:png", 103 | "dep:vnc", 104 | "dep:serde_win_unattend", 105 | "dep:axum", 106 | ] 107 | 108 | # Bundled OVMF firmware 109 | include_ovmf = [] 110 | 111 | # GUI support using egui/eframe 112 | gui = [ 113 | "dep:eframe", 114 | "dep:egui", 115 | "dep:image", 116 | "dep:poll-promise", 117 | "dep:block-utils", 118 | ] 119 | 120 | # UKI (Unified Kernel Image) mode - GUI with automatic reboot 121 | uki = ["gui"] 122 | 123 | # Configuration formats 124 | config-json = ["dep:serde_json"] 125 | config-yaml = ["dep:serde_yaml"] 126 | config-ron = ["dep:ron"] 127 | config-toml = ["dep:toml"] 128 | config-python = ["dep:pyo3", "dep:pythonize"] 129 | -------------------------------------------------------------------------------- /goldboot/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | ARG TARGETOS 4 | ARG TARGETARCH 5 | ARG TARGETVARIANT 6 | 7 | RUN apk add --no-cache \ 8 | gtk4.0 \ 9 | qemu \ 10 | qemu-img \ 11 | qemu-system-aarch64 \ 12 | qemu-system-arm \ 13 | qemu-system-x86_64 14 | 15 | COPY ${TARGETOS}-${TARGETARCH}${TARGETVARIANT}/goldboot /usr/bin/goldboot 16 | RUN chmod +x /usr/bin/goldboot 17 | 18 | WORKDIR /root 19 | ENTRYPOINT ["/usr/bin/goldboot"] 20 | -------------------------------------------------------------------------------- /goldboot/README.md: -------------------------------------------------------------------------------- 1 | ## goldboot 2 | 3 | Contains the command-line application for building, pushing, pulling, and writing goldboot images. -------------------------------------------------------------------------------- /goldboot/build.rs: -------------------------------------------------------------------------------- 1 | use std::{env, path::PathBuf}; 2 | 3 | fn main() { 4 | if built::write_built_file().is_err() { 5 | let dest = 6 | std::path::Path::new(&env::var("OUT_DIR").expect("OUT_DIR not set")).join("built.rs"); 7 | built::write_built_file_with_opts(Some(&PathBuf::from("..")), &dest) 8 | .expect("Failed to acquire build-time information"); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /goldboot/examples/apply_image_demo.rs: -------------------------------------------------------------------------------- 1 | //! Demo of the apply_image screen with simulated image writing. 2 | //! 3 | //! This example demonstrates the block visualization and progress tracking in the GUI 4 | //! by simulating an image write operation with realistic speeds and progress updates. 5 | //! 6 | //! Run with: cargo run --example apply_image_demo --features gui 7 | 8 | use goldboot::gui::{ 9 | app::GuiApp, 10 | state::{AppState, BlockState, WriteProgress}, 11 | screens::Screen, 12 | }; 13 | use std::{ 14 | sync::{Arc, Mutex}, 15 | thread, 16 | time::{Duration, Instant}, 17 | }; 18 | 19 | fn main() -> Result<(), eframe::Error> { 20 | // Initialize logging 21 | tracing_subscriber::fmt() 22 | .with_env_filter( 23 | tracing_subscriber::EnvFilter::from_default_env() 24 | .add_directive(tracing::Level::INFO.into()), 25 | ) 26 | .init(); 27 | 28 | // Create a custom app state that starts directly on the ApplyImage screen 29 | let mut state = AppState::new(); 30 | 31 | // Simulate a 1GB image write 32 | let total_size = 1024 * 1024 * 1024u64; 33 | state.init_write_progress(total_size); 34 | 35 | // Clone the progress Arc for the write thread 36 | let progress_arc = state.write_progress.clone().unwrap(); 37 | 38 | // Spawn a background thread to perform simulated write with realistic speeds 39 | thread::spawn(move || { 40 | let start_time = Instant::now(); 41 | let mut last_update = Instant::now(); 42 | 43 | // Simulate write speed of ~400 MB/s 44 | let target_write_speed = 400_000_000.0; // bytes per second 45 | let mut bytes_written = 0u64; 46 | let block_size = 4 * 1024 * 1024u64; // 4MB blocks 47 | 48 | while bytes_written < total_size { 49 | // Sleep to simulate realistic write speed 50 | thread::sleep(Duration::from_millis(10)); 51 | 52 | let now = Instant::now(); 53 | let elapsed_ms = now.duration_since(last_update).as_millis() as f64; 54 | 55 | // Calculate how many bytes we should have written in this interval 56 | let bytes_to_write = ((target_write_speed / 1000.0) * elapsed_ms) as u64; 57 | let bytes_to_write = bytes_to_write.min(total_size - bytes_written); 58 | 59 | bytes_written += bytes_to_write; 60 | last_update = now; 61 | 62 | if let Ok(mut progress) = progress_arc.lock() { 63 | // Update percentage 64 | progress.percentage = bytes_written as f32 / total_size as f32; 65 | 66 | // Calculate current block index 67 | let current_block = (bytes_written / block_size) as usize; 68 | let current_block = current_block.min(progress.blocks_total.saturating_sub(1)); 69 | 70 | // Mark completed blocks as written 71 | for i in 0..current_block { 72 | if i < progress.block_states.len() && progress.block_states[i] != BlockState::Written { 73 | progress.block_states[i] = BlockState::Written; 74 | progress.blocks_written += 1; 75 | } 76 | } 77 | 78 | // Mark current block(s) as writing (simulate 2-3 blocks writing simultaneously) 79 | progress.blocks_writing = 0; 80 | for i in current_block..=(current_block + 2).min(progress.blocks_total.saturating_sub(1)) { 81 | if i < progress.block_states.len() && progress.block_states[i] == BlockState::Pending { 82 | progress.block_states[i] = BlockState::Writing; 83 | progress.blocks_writing += 1; 84 | } 85 | } 86 | 87 | // Update speeds with some realistic variation 88 | let speed_variation = (rand::random::() - 0.5) * 0.1; // ±10% variation 89 | progress.write_speed = target_write_speed * (1.0 + speed_variation); 90 | progress.read_speed = progress.write_speed * 1.05; // Read slightly faster 91 | } 92 | } 93 | 94 | // Mark all blocks as written when complete 95 | if let Ok(mut progress) = progress_arc.lock() { 96 | progress.percentage = 1.0; 97 | for i in 0..progress.blocks_total { 98 | if i < progress.block_states.len() && progress.block_states[i] != BlockState::Written { 99 | progress.block_states[i] = BlockState::Written; 100 | progress.blocks_written += 1; 101 | } 102 | } 103 | progress.blocks_writing = 0; 104 | } 105 | 106 | println!("Simulated write completed in {:.2}s", start_time.elapsed().as_secs_f64()); 107 | }); 108 | 109 | // Create and run the GUI app starting on ApplyImage screen 110 | let native_options = eframe::NativeOptions { 111 | viewport: egui::ViewportBuilder::default() 112 | .with_inner_size([1024.0, 768.0]) 113 | .with_title("Goldboot - Apply Image Demo"), 114 | ..Default::default() 115 | }; 116 | 117 | // Create app with custom initial state 118 | eframe::run_native( 119 | "goldboot", 120 | native_options, 121 | Box::new(move |cc| { 122 | Ok(Box::new(GuiApp { 123 | state, 124 | textures: goldboot::gui::resources::TextureCache::new(&cc.egui_ctx), 125 | theme: goldboot::gui::theme::Theme::default(), 126 | screen: Screen::ApplyImage, // Start directly on ApplyImage 127 | })) 128 | }), 129 | ) 130 | } 131 | -------------------------------------------------------------------------------- /goldboot/flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1763966396, 24 | "narHash": "sha256-6eeL1YPcY1MV3DDStIDIdy/zZCDKgHdkCmsrLJFiZf0=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "5ae3b07d8d6527c42f17c876e404993199144b6a", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /goldboot/goldboot.json: -------------------------------------------------------------------------------- 1 | { 2 | "os": "ArchLinux", 3 | "hostname": "goldboot", 4 | "mirrorlist": null, 5 | "root_password": { 6 | "plaintext": "root" 7 | }, 8 | "iso": { 9 | "url": "http://mirrors.edge.kernel.org/archlinux/iso/latest/archlinux-2025.10.01-x86_64.iso", 10 | "checksum": null 11 | } 12 | } -------------------------------------------------------------------------------- /goldboot/src/builder/fabricators/ansible.rs: -------------------------------------------------------------------------------- 1 | use crate::builder::Builder; 2 | use crate::{builder::ssh::SshConnection, cli::prompt::Prompt}; 3 | use anyhow::Result; 4 | use anyhow::bail; 5 | use serde::{Deserialize, Serialize}; 6 | use std::{path::Path, process::Command}; 7 | use tracing::info; 8 | use validator::Validate; 9 | 10 | use super::Fabricate; 11 | 12 | /// Runs an Ansible playbook on the image remotely. 13 | #[derive(Clone, Serialize, Deserialize, Validate, Debug)] 14 | pub struct Ansible { 15 | /// The playbook file 16 | pub playbook: String, 17 | 18 | /// The inventory file 19 | pub inventory: Option, 20 | } 21 | 22 | impl Ansible { 23 | pub fn run(&self, ssh: &mut SshConnection) -> Result<()> { 24 | info!("Running ansible provisioner"); 25 | 26 | if let Some(code) = Command::new("ansible-playbook") 27 | .arg("--ssh-common-args") 28 | .arg("-o StrictHostKeyChecking=no") 29 | .arg("-e") 30 | .arg(format!("ansible_port={}", ssh.port)) 31 | .arg("-e") 32 | .arg(format!("ansible_user={}", ssh.username)) 33 | .arg("-e") 34 | .arg(format!( 35 | "ansible_ssh_private_key_file={}", 36 | ssh.private_key.display() 37 | )) 38 | .arg("-e") 39 | .arg("ansible_connection=ssh") 40 | .arg(&self.playbook) 41 | .status() 42 | .expect("Failed to launch ansible-playbook") 43 | .code() 44 | { 45 | if code != 0 { 46 | bail!("Provisioning failed"); 47 | } 48 | } 49 | 50 | Ok(()) 51 | } 52 | } 53 | 54 | impl Fabricate for Ansible { 55 | fn run(&self, _ssh: &mut SshConnection) -> Result<()> { 56 | todo!() 57 | } 58 | } 59 | 60 | impl Prompt for Ansible { 61 | fn prompt(&mut self, _: &Builder) -> Result<()> { 62 | self.playbook = dialoguer::Input::with_theme(&crate::cli::cmd::init::theme()) 63 | .with_prompt("Enter the playbook path relative to the current directory") 64 | .interact()?; 65 | 66 | if !Path::new(&self.playbook).exists() { 67 | if !dialoguer::Confirm::with_theme(&crate::cli::cmd::init::theme()) 68 | .with_prompt("The path does not exist. Add anyway?") 69 | .interact()? 70 | { 71 | bail!("The playbook did not exist"); 72 | } 73 | } 74 | 75 | self.validate()?; 76 | Ok(()) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /goldboot/src/builder/fabricators/exe.rs: -------------------------------------------------------------------------------- 1 | use super::Fabricate; 2 | use crate::builder::Builder; 3 | use crate::{builder::ssh::SshConnection, cli::prompt::Prompt}; 4 | use anyhow::Result; 5 | use anyhow::bail; 6 | use serde::{Deserialize, Serialize}; 7 | use std::path::Path; 8 | use tracing::info; 9 | use validator::Validate; 10 | 11 | /// Runs an executable file. 12 | #[derive(Clone, Serialize, Deserialize, Validate, Debug)] 13 | pub struct HostExecutable { 14 | /// The path to the executable 15 | pub path: String, 16 | } 17 | 18 | impl Fabricate for HostExecutable { 19 | fn run(&self, ssh: &mut SshConnection) -> Result<()> { 20 | info!("Running executable"); 21 | 22 | if ssh.upload_exec(&std::fs::read(&self.path)?, vec![])? != 0 { 23 | bail!("Executable failed"); 24 | } 25 | Ok(()) 26 | } 27 | } 28 | 29 | impl Prompt for HostExecutable { 30 | fn prompt(&mut self, _: &Builder) -> Result<()> { 31 | self.path = dialoguer::Input::with_theme(&crate::cli::cmd::init::theme()) 32 | .with_prompt("Enter the script path relative to the current directory") 33 | .interact()?; 34 | 35 | if !Path::new(&self.path).exists() { 36 | if !dialoguer::Confirm::with_theme(&crate::cli::cmd::init::theme()) 37 | .with_prompt("The path does not exist. Add anyway?") 38 | .interact()? 39 | { 40 | bail!("The playbook did not exist"); 41 | } 42 | } 43 | 44 | self.validate()?; 45 | Ok(()) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /goldboot/src/builder/fabricators/mod.rs: -------------------------------------------------------------------------------- 1 | //! This module contains various common provisioners which may be included in 2 | //! image templates. Templates may also specify their own specialized 3 | //! provisioners for specific tasks. 4 | 5 | use crate::builder::ssh::SshConnection; 6 | use ansible::Ansible; 7 | use anyhow::Result; 8 | use enum_dispatch::enum_dispatch; 9 | use serde::{Deserialize, Serialize}; 10 | 11 | pub mod ansible; 12 | pub mod exe; 13 | pub mod shell; 14 | 15 | /// A `Fabricator` performs some custom operation on an image at the very end of 16 | /// the build process. 17 | #[enum_dispatch(Fabricator)] 18 | pub trait Fabricate { 19 | fn run(&self, ssh: &mut SshConnection) -> Result<()>; 20 | } 21 | 22 | #[enum_dispatch] 23 | #[derive(Clone, Serialize, Deserialize, Debug)] 24 | pub enum Fabricator { 25 | Ansible, 26 | } 27 | -------------------------------------------------------------------------------- /goldboot/src/builder/fabricators/shell.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use anyhow::bail; 3 | use serde::{Deserialize, Serialize}; 4 | use tracing::info; 5 | use validator::Validate; 6 | 7 | use crate::builder::ssh::SshConnection; 8 | 9 | /// Runs an inline shell command. 10 | #[derive(Clone, Serialize, Deserialize, Validate, Debug)] 11 | pub struct ShellFabricator { 12 | /// The inline command to run 13 | pub command: String, 14 | } 15 | 16 | impl ShellFabricator { 17 | /// Create a new shell fabricator with inline command 18 | pub fn new(command: &str) -> Self { 19 | Self { 20 | command: command.to_string(), 21 | } 22 | } 23 | 24 | pub fn run(&self, ssh: &mut SshConnection) -> Result<()> { 25 | info!("Running shell commands"); 26 | 27 | if ssh.exec(&self.command)? != 0 { 28 | bail!("Shell commands failed"); 29 | } 30 | Ok(()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /goldboot/src/builder/http.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use axum::{Router, extract::MatchedPath, http::Request}; 3 | use std::path::Path; 4 | use tempfile::TempDir; 5 | use tokio::runtime::Runtime; 6 | use tower_http::{ 7 | services::ServeFile, 8 | trace::TraceLayer, 9 | }; 10 | use tracing::{debug, debug_span, info}; 11 | 12 | /// Minimal HTTP server for serving files to virtual machines 13 | pub struct HttpServer { 14 | pub port: u16, 15 | pub address: String, 16 | pub directory: TempDir, 17 | } 18 | 19 | impl HttpServer { 20 | pub fn new() -> Result { 21 | Ok(HttpServerBuilder { 22 | router: Router::new(), 23 | directory: tempfile::tempdir()?, 24 | }) 25 | } 26 | } 27 | 28 | pub struct HttpServerBuilder { 29 | router: Router, 30 | directory: TempDir, 31 | } 32 | 33 | impl HttpServerBuilder { 34 | pub fn file(mut self, path: &str, data: C) -> Result 35 | where 36 | C: AsRef<[u8]>, 37 | { 38 | let tmp_path = self.directory.path().join(Path::new(path)); 39 | std::fs::create_dir_all(tmp_path.parent().expect("tempdir has a parent"))?; 40 | std::fs::write(&tmp_path, data)?; 41 | 42 | let path = format!("/{}", path.trim_start_matches("/")); 43 | debug!(path = %path, tmp_path = ?tmp_path, "Registered HTTP route"); 44 | self.router = self.router.route_service(&path, ServeFile::new(tmp_path)); 45 | Ok(self) 46 | } 47 | 48 | pub fn serve(self) -> HttpServer { 49 | let port = crate::find_open_port(8000, 9000); 50 | info!("Starting HTTP server on port: {}", port); 51 | 52 | let router = self.router.layer(TraceLayer::new_for_http().make_span_with( 53 | |request: &Request<_>| { 54 | // Log the matched route's path (with placeholders not filled in). 55 | // Use request.uri() or OriginalUri if you want the real path. 56 | let matched_path = request 57 | .extensions() 58 | .get::() 59 | .map(MatchedPath::as_str); 60 | 61 | debug_span!( 62 | "http_request", 63 | method = ?request.method(), 64 | matched_path, 65 | ) 66 | }, 67 | )); 68 | 69 | std::thread::spawn(move || { 70 | Runtime::new().unwrap().block_on(async move { 71 | let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)) 72 | .await 73 | .unwrap(); 74 | axum::serve(listener, router).await.unwrap(); 75 | }); 76 | }); 77 | 78 | HttpServer { 79 | port, 80 | address: "10.0.2.2".to_string(), 81 | directory: self.directory, 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /goldboot/src/builder/mod.rs: -------------------------------------------------------------------------------- 1 | use self::qemu::{Accel, detect_accel}; 2 | use self::{fabricators::Fabricator, os::Os}; 3 | use crate::builder::os::BuildImage; 4 | use crate::cli::cmd::Commands; 5 | use crate::library::ImageLibrary; 6 | use crate::size; 7 | 8 | use anyhow::{Result, anyhow, bail}; 9 | use byte_unit::Byte; 10 | use dialoguer::Password; 11 | use goldboot_image::ElementHeader; 12 | use goldboot_image::{ImageArch, ImageHandle, qcow::Qcow3}; 13 | use rand::Rng; 14 | use serde::{Deserialize, Serialize}; 15 | use std::{ 16 | path::{Path, PathBuf}, 17 | thread, 18 | time::SystemTime, 19 | }; 20 | use tracing::info; 21 | use validator::Validate; 22 | 23 | pub mod fabricators; 24 | pub mod http; 25 | pub mod options; 26 | pub mod os; 27 | pub mod ovmf; 28 | pub mod qemu; 29 | pub mod sources; 30 | pub mod ssh; 31 | pub mod vnc; 32 | 33 | /// Machinery that creates Goldboot images from image elements. 34 | #[derive(Validate)] 35 | pub struct Builder { 36 | pub elements: Vec, 37 | 38 | pub accel: Accel, 39 | 40 | pub debug: bool, 41 | 42 | pub record: bool, 43 | 44 | /// A general purpose temporary directory for the run 45 | pub tmp: tempfile::TempDir, 46 | 47 | pub ovmf_path: PathBuf, 48 | pub qcow_path: PathBuf, 49 | 50 | /// VNC port for the VM 51 | pub vnc_port: u16, 52 | 53 | /// End time of the run 54 | pub end_time: Option, 55 | 56 | /// Start time of the run 57 | pub start_time: Option, 58 | } 59 | 60 | impl Builder { 61 | pub fn new(elements: Vec) -> Self { 62 | // Allocate directory for the builder to store the intermediate qcow image 63 | // and any other supporting files. 64 | let tmp = tempfile::tempdir().unwrap(); 65 | 66 | Self { 67 | accel: detect_accel(), 68 | debug: false, 69 | record: false, 70 | end_time: None, 71 | qcow_path: tmp.path().join("image.gb.qcow2"), 72 | start_time: None, 73 | vnc_port: rand::rng().random_range(5900..5999), 74 | elements, 75 | ovmf_path: tmp.path().join("OVMF.fd"), 76 | tmp, 77 | } 78 | } 79 | 80 | /// The system architecture 81 | pub fn arch(&self) -> Result { 82 | match self.elements.first() { 83 | Some(element) => { 84 | todo!() 85 | } 86 | None => bail!("No elements in builder"), 87 | } 88 | } 89 | 90 | /// Run the image build process according to the given command line. 91 | pub fn run(&mut self, cli: Commands) -> Result<()> { 92 | self.start_time = Some(SystemTime::now()); 93 | 94 | let qcow_size: u64 = self 95 | .elements 96 | .iter() 97 | .map(|element| -> u64 { size!(element).into() }) 98 | .sum(); 99 | 100 | match cli { 101 | Commands::Build { 102 | record, 103 | debug, 104 | read_password, 105 | no_accel, 106 | output, 107 | path, 108 | ovmf_path, 109 | } => { 110 | self.debug = debug; 111 | self.record = record; 112 | 113 | // Set VNC port predictably in debug mode 114 | if debug { 115 | self.vnc_port = 5900; 116 | } 117 | 118 | // Prompt password 119 | let password = if read_password { 120 | Some( 121 | Password::with_theme(&crate::cli::cmd::init::theme()) 122 | .with_prompt("Image encryption passphrase") 123 | .interact()?, 124 | ) 125 | } else { 126 | None 127 | }; 128 | 129 | // Disable VM acceleration if requested 130 | if no_accel { 131 | self.accel = Accel::Tcg; 132 | } 133 | 134 | // Override from command line 135 | if let Some(path) = ovmf_path { 136 | self.ovmf_path = PathBuf::from(path); 137 | } else { 138 | // Try to find OVMF firmware or unpack what's included 139 | if let Some(path) = crate::builder::ovmf::find() { 140 | self.ovmf_path = path; 141 | } else if cfg!(feature = "include_ovmf") { 142 | let path = self 143 | .tmp 144 | .path() 145 | .join("OVMF.fd") 146 | .to_string_lossy() 147 | .to_string(); 148 | 149 | #[cfg(feature = "include_ovmf")] 150 | crate::builder::ovmf::write(self.arch()?, &path).unwrap(); 151 | self.ovmf_path = PathBuf::from(path); 152 | } 153 | } 154 | 155 | // Check OVMF firmware path 156 | if !self.ovmf_path.exists() { 157 | bail!("No OVMF firmware found"); 158 | } 159 | 160 | // Truncate the image size to a power of two for the qcow storage 161 | let qcow = Qcow3::create(&self.qcow_path, qcow_size - (qcow_size % 2))?; 162 | for element in self.elements.clone().into_iter() { 163 | element.build(&self)?; 164 | } 165 | 166 | // Convert into final immutable image 167 | let path = if let Some(output) = output.as_ref() { 168 | PathBuf::from(output) 169 | } else { 170 | ImageLibrary::open().temporary() 171 | }; 172 | 173 | ImageHandle::from_qcow(Vec::new(), &qcow, &path, password, |_, _| {})?; 174 | 175 | if let None = output { 176 | ImageLibrary::open().add_move(path.clone())?; 177 | } 178 | } 179 | _ => panic!("Must be passed a Commands::Build"), 180 | } 181 | 182 | self.end_time = Some(SystemTime::now()); 183 | info!( 184 | duration = ?self.start_time.unwrap().elapsed()?, 185 | "Build completed", 186 | ); 187 | 188 | Ok(()) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /goldboot/src/builder/options/arch.rs: -------------------------------------------------------------------------------- 1 | use crate::{builder::Builder, cli::prompt::Prompt}; 2 | use anyhow::Result; 3 | use goldboot_image::ImageArch; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Clone, Serialize, Deserialize, Debug)] 7 | pub struct Arch(pub ImageArch); 8 | 9 | impl Prompt for Arch { 10 | fn prompt(&mut self, builder: &Builder) -> Result<()> { 11 | todo!() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /goldboot/src/builder/options/dns_resolver.rs: -------------------------------------------------------------------------------- 1 | pub struct DnsResolverProvisioner {} 2 | -------------------------------------------------------------------------------- /goldboot/src/builder/options/hostname.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use crate::{builder::Builder, cli::prompt::Prompt}; 4 | use anyhow::Result; 5 | use serde::{Deserialize, Serialize}; 6 | use validator::Validate; 7 | 8 | /// Sets the network hostname. 9 | #[derive(Clone, Serialize, Deserialize, Validate, Debug)] 10 | pub struct Hostname { 11 | // TODO validate 12 | pub hostname: String, 13 | } 14 | 15 | impl Display for Hostname { 16 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 17 | write!(f, "{}", self.hostname) 18 | } 19 | } 20 | 21 | impl Default for Hostname { 22 | fn default() -> Self { 23 | Self { 24 | hostname: String::from("goldboot"), 25 | } 26 | } 27 | } 28 | 29 | impl Prompt for Hostname { 30 | fn prompt(&mut self, builder: &Builder) -> Result<()> { 31 | self.hostname = dialoguer::Input::with_theme(&crate::cli::cmd::init::theme()) 32 | .with_prompt("Enter network hostname") 33 | // .default(builder.name.clone()) 34 | .interact()?; 35 | 36 | self.validate()?; 37 | Ok(()) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /goldboot/src/builder/options/iso.rs: -------------------------------------------------------------------------------- 1 | use crate::{builder::Builder, cli::prompt::Prompt}; 2 | use anyhow::Result; 3 | use serde::{Deserialize, Serialize}; 4 | use url::Url; 5 | use validator::Validate; 6 | 7 | /// Use an ISO image as a source. 8 | #[derive(Clone, Serialize, Deserialize, Validate, Debug)] 9 | pub struct Iso { 10 | /// The installation media URL (http, https, or file) 11 | pub url: Url, 12 | 13 | /// A hash of the installation media 14 | pub checksum: Option, 15 | } 16 | 17 | impl Prompt for Iso { 18 | fn prompt(&mut self, _: &Builder) -> Result<()> { 19 | self.url = dialoguer::Input::with_theme(&crate::cli::cmd::init::theme()) 20 | .with_prompt("Enter the ISO URL") 21 | .interact()?; 22 | 23 | self.validate()?; 24 | Ok(()) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /goldboot/src/builder/options/locale.rs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /goldboot/src/builder/options/luks.rs: -------------------------------------------------------------------------------- 1 | use crate::{builder::Builder, cli::prompt::Prompt}; 2 | use anyhow::Result; 3 | use dialoguer::{Confirm, Password}; 4 | use serde::{Deserialize, Serialize}; 5 | use validator::Validate; 6 | 7 | /// Configures a LUKS encrypted root filesystem 8 | #[derive(Clone, Serialize, Deserialize, Validate, Debug)] 9 | pub struct Luks { 10 | /// The LUKS passphrase 11 | pub passphrase: String, 12 | 13 | /// Whether the LUKS passphrase will be enrolled in a TPM 14 | pub tpm: bool, 15 | } 16 | 17 | impl Prompt for Luks { 18 | fn prompt(&mut self, _: &Builder) -> Result<()> { 19 | if Confirm::with_theme(&crate::cli::cmd::init::theme()) 20 | .with_prompt("Do you want to encrypt the root partition with LUKS?") 21 | .interact()? 22 | { 23 | self.passphrase = Password::with_theme(&crate::cli::cmd::init::theme()) 24 | .with_prompt("LUKS passphrase") 25 | .interact()?; 26 | } 27 | 28 | self.validate()?; 29 | Ok(()) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /goldboot/src/builder/options/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod arch; 2 | pub mod hostname; 3 | pub mod iso; 4 | pub mod luks; 5 | pub mod size; 6 | pub mod timezone; 7 | pub mod unix_account; 8 | -------------------------------------------------------------------------------- /goldboot/src/builder/options/size.rs: -------------------------------------------------------------------------------- 1 | use crate::{builder::Builder, cli::prompt::Prompt}; 2 | use anyhow::Result; 3 | use byte_unit::Byte; 4 | use serde::{Deserialize, Serialize}; 5 | use validator::Validate; 6 | 7 | /// Absolute size of an image element. 8 | #[derive(Clone, Serialize, Deserialize, Debug)] 9 | pub struct Size(String); 10 | 11 | impl Default for Size { 12 | fn default() -> Self { 13 | Self("16G".to_string()) 14 | } 15 | } 16 | 17 | impl Prompt for Size { 18 | fn prompt(&mut self, builder: &Builder) -> Result<()> { 19 | todo!() 20 | } 21 | } 22 | 23 | impl Validate for Size { 24 | fn validate(&self) -> std::result::Result<(), validator::ValidationErrors> { 25 | // Try to parse the size string using byte-unit 26 | match self.0.parse::() { 27 | Ok(byte) => { 28 | // Ensure the size is greater than zero 29 | if byte.as_u64() == 0 { 30 | let mut errors = validator::ValidationErrors::new(); 31 | errors.add( 32 | "size", 33 | validator::ValidationError::new("Size must be greater than zero"), 34 | ); 35 | return Err(errors); 36 | } 37 | Ok(()) 38 | } 39 | Err(_) => { 40 | let mut errors = validator::ValidationErrors::new(); 41 | errors.add( 42 | "size", 43 | validator::ValidationError::new("Invalid size format. Expected format: number followed by unit (e.g., '16G', '512M', '1T')"), 44 | ); 45 | Err(errors) 46 | } 47 | } 48 | } 49 | } 50 | 51 | impl Into for Size { 52 | fn into(self) -> u64 { 53 | // Assume Size was validated previously 54 | self.0 55 | .parse::() 56 | .expect("Size was not validated") 57 | .as_u64() 58 | } 59 | } 60 | 61 | #[cfg(test)] 62 | mod tests { 63 | use super::*; 64 | 65 | #[test] 66 | fn test_valid_sizes() { 67 | // Test various valid size formats 68 | let sizes = vec![ 69 | "16G", "16GB", "512M", "512MB", "1T", "1TB", "2048K", "2048KB", "1024", "1 GB", 70 | "1.5 GB", "100 MiB", "16GiB", 71 | ]; 72 | 73 | for size_str in sizes { 74 | let size = Size(size_str.to_string()); 75 | assert!( 76 | size.validate().is_ok(), 77 | "Expected '{}' to be valid", 78 | size_str 79 | ); 80 | } 81 | } 82 | 83 | #[test] 84 | fn test_invalid_sizes() { 85 | // Test various invalid size formats 86 | let invalid_sizes = vec![ 87 | "", // Empty string 88 | "abc", // No numbers 89 | "G16", // Unit before number 90 | "16X", // Invalid unit 91 | "hello world", // Completely invalid 92 | ]; 93 | 94 | for size_str in invalid_sizes { 95 | let size = Size(size_str.to_string()); 96 | assert!( 97 | size.validate().is_err(), 98 | "Expected '{}' to be invalid", 99 | size_str 100 | ); 101 | } 102 | } 103 | 104 | #[test] 105 | fn test_zero_size() { 106 | // Test that zero size is rejected 107 | let size = Size("0".to_string()); 108 | assert!(size.validate().is_err(), "Expected zero size to be invalid"); 109 | 110 | let size = Size("0GB".to_string()); 111 | assert!(size.validate().is_err(), "Expected zero size to be invalid"); 112 | } 113 | 114 | #[test] 115 | fn test_default_size() { 116 | // Test that the default size is valid 117 | let size = Size::default(); 118 | assert!( 119 | size.validate().is_ok(), 120 | "Expected default size '{}' to be valid", 121 | size.0 122 | ); 123 | } 124 | 125 | #[test] 126 | fn test_large_sizes() { 127 | // Test large sizes 128 | let sizes = vec!["1000T", "1000TB", "1PB", "100000GB"]; 129 | 130 | for size_str in sizes { 131 | let size = Size(size_str.to_string()); 132 | assert!( 133 | size.validate().is_ok(), 134 | "Expected large size '{}' to be valid", 135 | size_str 136 | ); 137 | } 138 | } 139 | 140 | #[test] 141 | fn test_bytes_only() { 142 | // Test sizes specified in bytes only 143 | let size = Size("1073741824".to_string()); // 1GB in bytes 144 | assert!( 145 | size.validate().is_ok(), 146 | "Expected bytes-only size to be valid" 147 | ); 148 | } 149 | 150 | #[test] 151 | fn test_binary_vs_decimal_units() { 152 | // Test both binary (GiB) and decimal (GB) units 153 | let size_binary = Size("16GiB".to_string()); 154 | assert!( 155 | size_binary.validate().is_ok(), 156 | "Expected binary unit GiB to be valid" 157 | ); 158 | 159 | let size_decimal = Size("16GB".to_string()); 160 | assert!( 161 | size_decimal.validate().is_ok(), 162 | "Expected decimal unit GB to be valid" 163 | ); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /goldboot/src/builder/options/timezone.rs: -------------------------------------------------------------------------------- 1 | use crate::{builder::Builder, cli::prompt::Prompt}; 2 | use anyhow::Result; 3 | use serde::{Deserialize, Serialize}; 4 | use validator::Validate; 5 | 6 | #[derive(Clone, Serialize, Deserialize, Validate, Debug)] 7 | pub struct Timezone { 8 | // TODO 9 | } 10 | 11 | impl Prompt for Timezone { 12 | fn prompt(&mut self, _: &Builder) -> Result<()> { 13 | todo!() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /goldboot/src/builder/options/unix_account.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use crate::{builder::Builder, cli::prompt::Prompt}; 4 | use anyhow::Result; 5 | use dialoguer::Password; 6 | use serde::{Deserialize, Serialize}; 7 | use validator::Validate; 8 | 9 | // impl UnixAccountProvisioners { 10 | // /// Get the root user's password 11 | // pub fn get_root_password(&self) -> Option { 12 | // self.users 13 | // .iter() 14 | // .filter(|u| u.username == "root") 15 | // .map(|u| u.password) 16 | // .next() 17 | // } 18 | // } 19 | 20 | /// This provisioner configures a UNIX-like user account. 21 | #[derive(Clone, Serialize, Deserialize, Validate, Debug)] 22 | pub struct UnixAccountProvisioner { 23 | #[validate(length(max = 64))] 24 | pub username: String, 25 | 26 | #[validate(length(max = 64))] 27 | pub password: String, 28 | } 29 | 30 | impl Prompt for UnixAccountProvisioner { 31 | fn prompt(&mut self, _: &Builder) -> Result<()> { 32 | let theme = crate::cli::cmd::init::theme(); 33 | self.password = Password::with_theme(&theme) 34 | .with_prompt("Root password") 35 | .interact()?; 36 | 37 | self.validate()?; 38 | Ok(()) 39 | } 40 | } 41 | 42 | impl Default for UnixAccountProvisioner { 43 | fn default() -> Self { 44 | Self { 45 | username: String::from("root"), 46 | password: crate::random_password(), 47 | } 48 | } 49 | } 50 | 51 | #[derive(Clone, Serialize, Deserialize, Debug)] 52 | #[serde(rename_all = "snake_case")] 53 | pub enum RootPassword { 54 | /// Simple plaintext password 55 | Plaintext(String), 56 | 57 | /// Take plaintext password from environment variable 58 | PlaintextEnv(String), 59 | } 60 | 61 | impl Default for RootPassword { 62 | fn default() -> Self { 63 | Self::Plaintext("root".to_string()) 64 | } 65 | } 66 | 67 | impl Prompt for RootPassword { 68 | fn prompt(&mut self, _: &Builder) -> Result<()> { 69 | let theme = crate::cli::cmd::init::theme(); 70 | *self = RootPassword::Plaintext( 71 | Password::with_theme(&theme) 72 | .with_prompt("Root password") 73 | .interact()?, 74 | ); 75 | Ok(()) 76 | } 77 | } 78 | 79 | impl Display for RootPassword { 80 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 81 | write!( 82 | f, 83 | "{}", 84 | match &self { 85 | RootPassword::Plaintext(password) => format!("plain:{password}"), 86 | RootPassword::PlaintextEnv(name) => format!( 87 | "plain:{}", 88 | std::env::var(name).expect("environment variable not found") 89 | ), 90 | } 91 | ) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /goldboot/src/builder/os/alpine_linux/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/alpine_linux/icon.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/alpine_linux/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/alpine_linux/icon@2x.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/alpine_linux/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use goldboot_image::ImageArch; 3 | use serde::{Deserialize, Serialize}; 4 | use smart_default::SmartDefault; 5 | use std::fmt::Display; 6 | use strum::{Display, EnumIter, IntoEnumIterator}; 7 | use validator::Validate; 8 | 9 | use crate::{ 10 | builder::{ 11 | Builder, 12 | options::{hostname::Hostname, iso::Iso, size::Size, unix_account::RootPassword}, 13 | qemu::{OsCategory, QemuBuilder}, 14 | }, 15 | cli::prompt::Prompt, 16 | enter, wait, wait_screen_rect, 17 | }; 18 | 19 | use super::BuildImage; 20 | 21 | /// Produces [Alpine Linux](https://www.alpinelinux.org) images. 22 | #[derive(Clone, Serialize, Deserialize, Validate, Debug, SmartDefault, goldboot_macros::Prompt)] 23 | pub struct AlpineLinux { 24 | pub size: Size, 25 | pub edition: AlpineEdition, 26 | #[serde(flatten)] 27 | pub hostname: Hostname, 28 | pub release: AlpineRelease, 29 | pub root_password: RootPassword, 30 | #[default(Iso { 31 | url: "https://dl-cdn.alpinelinux.org/alpine/v3.19/releases/x86_64/alpine-standard-3.19.1-x86_64.iso".parse().unwrap(), 32 | checksum: Some("sha256:12addd7d4154df1caf5f258b80ad72e7a724d33e75e6c2e6adc1475298d47155".to_string()), 33 | })] 34 | pub iso: Iso, 35 | } 36 | 37 | impl BuildImage for AlpineLinux { 38 | fn build(&self, worker: &Builder) -> Result<()> { 39 | let mut qemu = QemuBuilder::new(&worker, OsCategory::Linux) 40 | .with_iso(&self.iso)? 41 | .prepare_ssh()? 42 | .start()?; 43 | 44 | // Send boot command 45 | #[rustfmt::skip] 46 | qemu.vnc.run(vec![ 47 | // Initial wait 48 | wait!(30), 49 | // Root login 50 | enter!("root"), 51 | // Configure install 52 | enter!("export KEYMAPOPTS='us us'"), 53 | enter!(format!("export HOSTNAMEOPTS='-n {}'", self.hostname.hostname)), 54 | enter!("export INTERFACESOPTS=' 55 | auto lo 56 | iface lo inet loopback 57 | 58 | auto eth0 59 | iface eth0 inet dhcp 60 | hostname alpine-test'" 61 | ), 62 | enter!("export DNSOPTS='1.1.1.1'"), 63 | enter!("export TIMEZONEOPTS='-z UTC'"), 64 | enter!("export PROXYOPTS='none'"), 65 | enter!("export APKREPOSOPTS='-r'"), 66 | enter!("export SSHDOPTS='-c openssh'"), 67 | enter!("export NTPOPTS='-c openntpd'"), 68 | enter!("export DISKOPTS='-m sys /dev/vda'"), 69 | // Start install 70 | enter!("echo -e 'root\nroot\ny' | setup-alpine"), 71 | wait_screen_rect!("6d7b9fc9229c4f4ae8bc84f0925d8479ccd3e7d2", 668, 0, 1024, 100), 72 | // Remount root partition 73 | enter!("mount -t ext4 /dev/vda3 /mnt"), 74 | // Reboot into installation 75 | enter!("apk add efibootmgr; efibootmgr -n 0003; reboot"), 76 | ])?; 77 | 78 | // Wait for SSH 79 | let ssh = qemu.ssh("root")?; 80 | 81 | // Run provisioners 82 | // TODO 83 | 84 | // Shutdown 85 | ssh.shutdown("poweroff")?; 86 | qemu.shutdown_wait()?; 87 | Ok(()) 88 | } 89 | } 90 | 91 | #[derive(Clone, Copy, Serialize, Deserialize, Debug, EnumIter, Display, Default)] 92 | pub enum AlpineEdition { 93 | #[default] 94 | Standard, 95 | Extended, 96 | RaspberryPi, 97 | Xen, 98 | } 99 | 100 | impl Prompt for AlpineEdition { 101 | fn prompt(&mut self, _: &Builder) -> Result<()> { 102 | let theme = crate::cli::cmd::init::theme(); 103 | let editions: Vec = AlpineEdition::iter().collect(); 104 | let edition_index = dialoguer::Select::with_theme(&theme) 105 | .with_prompt("Choose an edition") 106 | .default(0) 107 | .items(&editions) 108 | .interact()?; 109 | 110 | *self = editions[edition_index]; 111 | Ok(()) 112 | } 113 | } 114 | 115 | #[derive(Clone, Copy, Serialize, Deserialize, Debug, EnumIter, Default)] 116 | pub enum AlpineRelease { 117 | #[default] 118 | Edge, 119 | #[serde(rename = "v3.19")] 120 | V3_19, 121 | #[serde(rename = "v3.18")] 122 | V3_18, 123 | #[serde(rename = "v3.17")] 124 | V3_17, 125 | #[serde(rename = "v3.16")] 126 | V3_16, 127 | #[serde(rename = "v3.15")] 128 | V3_15, 129 | } 130 | 131 | impl Prompt for AlpineRelease { 132 | fn prompt(&mut self, _: &Builder) -> Result<()> { 133 | Ok(()) 134 | } 135 | } 136 | 137 | impl Display for AlpineRelease { 138 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 139 | write!( 140 | f, 141 | "{}", 142 | match &self { 143 | AlpineRelease::Edge => "Edge", 144 | AlpineRelease::V3_19 => "v3.19", 145 | AlpineRelease::V3_18 => "v3.18", 146 | AlpineRelease::V3_17 => "v3.17", 147 | AlpineRelease::V3_16 => "v3.16", 148 | AlpineRelease::V3_15 => "v3.15", 149 | } 150 | ) 151 | } 152 | } 153 | 154 | // fn fetch_latest_iso( 155 | // edition: AlpineEdition, 156 | // release: AlpineRelease, 157 | // arch: Architecture, 158 | // ) -> Result { 159 | // let arch = match arch { 160 | // Architecture::amd64 => "x86_64", 161 | // Architecture::arm64 => "aarch64", 162 | // _ => bail!("Unsupported architecture"), 163 | // }; 164 | 165 | // let edition = match edition { 166 | // AlpineEdition::Standard => "standard", 167 | // AlpineEdition::Extended => "extended", 168 | // AlpineEdition::Xen => "virt", 169 | // AlpineEdition::RaspberryPi => "rpi", 170 | // }; 171 | 172 | // let url = format!("https://dl-cdn.alpinelinux.org/alpine/v3.16/releases/{arch}/alpine-{edition}-3.16.0-{arch}.iso"); 173 | 174 | // // Download checksum 175 | // let rs = reqwest::blocking::get(format!("{url}.sha256"))?; 176 | // let checksum = if rs.status().is_success() { None } else { None }; 177 | 178 | // Ok(IsoSource { url, checksum }) 179 | // } 180 | -------------------------------------------------------------------------------- /goldboot/src/builder/os/android/mod.rs: -------------------------------------------------------------------------------- 1 | use super::{CastImage, DefaultSource}; 2 | use crate::builder::Foundry; 3 | use crate::builder::options::hostname::Hostname; 4 | use crate::builder::options::unix_account::RootPassword; 5 | use crate::builder::qemu::QemuBuilder; 6 | use crate::cli::prompt::Prompt; 7 | use crate::wait; 8 | use crate::{ 9 | builder::{Builder, sources::ImageSource}, 10 | wait_screen_rect, 11 | }; 12 | use anyhow::Result; 13 | use anyhow::bail; 14 | use dialoguer::theme::Theme; 15 | use serde::{Deserialize, Serialize}; 16 | use std::io::{BufRead, BufReader}; 17 | use tracing::{debug, info}; 18 | use validator::Validate; 19 | 20 | /// Produces an AOSP image. 21 | #[derive(Clone, Serialize, Deserialize, Validate, Debug, goldboot_macros::Prompt)] 22 | pub struct Android {} 23 | 24 | impl Default for Android { 25 | fn default() -> Self { 26 | Self {} 27 | } 28 | } 29 | 30 | impl DefaultSource for Android { 31 | fn default_source(&self, arch: ImageArch) -> Result { 32 | todo!() 33 | } 34 | } 35 | 36 | impl CastImage for Android { 37 | fn cast(&self, worker: &Builder) -> Result<()> { 38 | let mut qemu = QemuBuilder::new(&worker).start()?; 39 | 40 | // Send boot command 41 | #[rustfmt::skip] 42 | qemu.vnc.run(vec![ 43 | // Initial wait 44 | wait!(30), 45 | // Wait for login 46 | wait_screen_rect!("5b3ca88689e9d671903b3040889c7fa1cb5f244a", 100, 0, 1024, 400), 47 | // Configure root password 48 | // enter!("passwd"), enter!(self.root_password), enter!(self.root_password), 49 | ])?; 50 | 51 | // Wait for SSH 52 | let mut ssh = qemu.ssh()?; 53 | 54 | // Run install script 55 | info!("Running base installation"); 56 | match ssh.upload_exec( 57 | include_bytes!("install.sh"), 58 | vec![ 59 | // ("GB_MIRRORLIST", &self.format_mirrorlist()), 60 | // ("GB_ROOT_PASSWORD", &self.root_password), 61 | ], 62 | ) { 63 | Ok(0) => debug!("Installation completed successfully"), 64 | _ => bail!("Installation failed"), 65 | } 66 | 67 | // Run provisioners 68 | // self.provisioners.run(&mut ssh)?; 69 | 70 | // Shutdown 71 | ssh.shutdown("poweroff")?; 72 | qemu.shutdown_wait()?; 73 | Ok(()) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /goldboot/src/builder/os/arch_linux/bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -x 5 | 6 | exec 1>&2 7 | 8 | # Don't block forever if we don't have enough entropy 9 | ln -sf /dev/urandom /dev/random 10 | 11 | # Synchronize time 12 | timedatectl set-ntp true 13 | 14 | # Wait for time sync 15 | while [ $(timedatectl show --property=NTPSynchronized --value) != "yes" ]; do 16 | sleep 5 17 | done 18 | 19 | # Wait for pacman-init to complete 20 | while ! systemctl show pacman-init.service | grep SubState=exited; do 21 | sleep 5 22 | done 23 | 24 | # Use a wrapper to run the install 25 | pacman -Sy --noconfirm --noprogressbar archinstall curl 26 | archinstall --debug --config <(curl "http://${GB_HTTP_HOST:?}:${GB_HTTP_PORT:?}/config.json") --creds <(curl "http://${GB_HTTP_HOST:?}:${GB_HTTP_PORT:?}/creds.json") 27 | 28 | exit 29 | 30 | # Create partitions 31 | parted --script -a optimal -- /dev/vda \ 32 | mklabel gpt \ 33 | mkpart primary 1MiB 256MiB \ 34 | set 1 esp on \ 35 | mkpart primary 256MiB 100% 36 | 37 | # Format boot partition 38 | mkfs.vfat /dev/vda1 39 | 40 | # Bootstrap filesystem 41 | pacstrap -K -M /mnt base linux linux-firmware efibootmgr grub dhcpcd ${GB_PACKAGES} 42 | 43 | if [ -e /dev/mapper/root ]; then 44 | cat <<-EOF >>/mnt/etc/default/grub 45 | GRUB_CMDLINE_LINUX="cryptdevice=UUID=$(blkid -s UUID -o value /dev/vda2):root root=/dev/mapper/root" 46 | EOF 47 | 48 | # Update initramfs 49 | echo 'HOOKS=(base udev autodetect keyboard keymap consolefont modconf block encrypt filesystems fsck)' >/mnt/etc/mkinitcpio.conf 50 | arch-chroot /mnt mkinitcpio -P 51 | else 52 | cat <<-EOF >>/mnt/etc/default/grub 53 | GRUB_CMDLINE_LINUX="root=UUID=$(blkid -s UUID -o value /dev/vda2)" 54 | EOF 55 | fi 56 | 57 | # Install bootloader 58 | arch-chroot /mnt grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=GRUB 59 | arch-chroot /mnt grub-mkconfig -o /boot/grub/grub.cfg 60 | 61 | -------------------------------------------------------------------------------- /goldboot/src/builder/os/arch_linux/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/arch_linux/icon.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/arch_linux/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/arch_linux/icon@2x.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/buildroot/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | build::BuildWorker, 3 | cache::{MediaCache, MediaFormat}, 4 | provisioners::*, 5 | qemu::QemuArgs, 6 | templates::*, 7 | }; 8 | use tracing::info; 9 | use serde::{Deserialize, Serialize}; 10 | use anyhow::bail; 11 | use std::{ 12 | error::Error, 13 | io::{BufRead, BufReader}, 14 | }; 15 | use validator::Validate; 16 | 17 | #[derive(Clone, Serialize, Deserialize, Validate, Debug)] 18 | pub struct BuildrootTemplate { 19 | pub source: BuildrootSource, 20 | pub provisioners: Option>, 21 | } 22 | 23 | pub enum ArchSource { 24 | Iso(IsoSource), 25 | } 26 | 27 | #[derive(Clone, Serialize, Deserialize, Debug)] 28 | #[serde(tag = "type", rename_all = "snake_case")] 29 | pub enum ArchProvisioner { 30 | Ansible(AnsibleProvisioner) 31 | Mirrorlist(ArchMirrorlistProvisioner), 32 | Hostname(HostnameProvisioner), 33 | } 34 | 35 | impl Default for ArchTemplate { 36 | fn default() -> Self { 37 | Self { 38 | source: None, 39 | provisioners: None, 40 | } 41 | } 42 | } 43 | 44 | impl BuildTemplate for ArchTemplate { 45 | fn build(&self, context: &BuildWorker) -> Result<()> { 46 | info!("Starting {} build", console::style("ArchLinux").blue()); 47 | 48 | let mut qemuargs = QemuArgs::new(&context); 49 | 50 | qemuargs.drive.push(format!( 51 | "file={},if=virtio,cache=writeback,discard=ignore,format=qcow2", 52 | context.image_path 53 | )); 54 | qemuargs.drive.push(format!( 55 | "file={},media=cdrom", 56 | MediaCache::get(self.iso.url.clone(), &self.iso.checksum, MediaFormat::Iso)? 57 | )); 58 | 59 | // Start VM 60 | let mut qemu = qemuargs.start_process()?; 61 | 62 | // Send boot command 63 | #[rustfmt::skip] 64 | qemu.vnc.boot_command(vec![ 65 | // Initial wait 66 | wait!(30), 67 | // Wait for login 68 | wait_screen_rect!("5b3ca88689e9d671903b3040889c7fa1cb5f244a", 100, 0, 1024, 400), 69 | // Configure root password 70 | enter!("passwd"), enter!(self.root_password), enter!(self.root_password), 71 | // Configure SSH 72 | enter!("echo 'AcceptEnv *' >>/etc/ssh/sshd_config"), 73 | enter!("echo 'PermitRootLogin yes' >>/etc/ssh/sshd_config"), 74 | // Start sshd 75 | enter!("systemctl restart sshd"), 76 | ])?; 77 | 78 | // Wait for SSH 79 | let mut ssh = qemu.ssh_wait(context.ssh_port, "root", &self.root_password)?; 80 | 81 | // Run install script 82 | if let Some(resource) = Resources::get("install.sh") { 83 | info!("Running base installation"); 84 | match ssh.upload_exec( 85 | resource.data.to_vec(), 86 | vec![ 87 | ("GB_MIRRORLIST", &self.format_mirrorlist()), 88 | ("GB_ROOT_PASSWORD", &self.root_password), 89 | ], 90 | ) { 91 | Ok(0) => debug!("Installation completed successfully"), 92 | _ => bail!("Installation failed"), 93 | } 94 | } 95 | 96 | // Run provisioners 97 | self.provisioners.run(&mut ssh)?; 98 | 99 | // Shutdown 100 | ssh.shutdown("poweroff")?; 101 | qemu.shutdown_wait()?; 102 | Ok(()) 103 | } 104 | } 105 | 106 | /// This provisioner configures the Archlinux mirror list. 107 | #[derive(Clone, Serialize, Deserialize, Validate, Debug)] 108 | pub struct ArchMirrorlistProvisioner { 109 | pub mirrors: Vec, 110 | } 111 | 112 | impl Default for ArchMirrorlistProvisioner { 113 | fn default() -> Self { 114 | Self { 115 | mirrors: vec![ 116 | String::from("https://geo.mirror.pkgbuild.com/"), 117 | String::from("https://mirror.rackspace.com/archlinux/"), 118 | String::from("https://mirrors.edge.kernel.org/archlinux/"), 119 | ], 120 | } 121 | } 122 | } 123 | 124 | impl ArchMirrorlistProvisioner { 125 | pub fn format_mirrorlist(&self) -> String { 126 | self.mirrors 127 | .iter() 128 | .map(|s| format!("Server = {}", s)) 129 | .collect::>() 130 | .join("\n") 131 | } 132 | } 133 | 134 | /// Fetch the latest installation ISO 135 | fn fetch_latest_iso(mirrorlist: ArchMirrorlistProvisioner) -> Result { 136 | for mirror in mirrorlist.mirrors { 137 | let rs = reqwest::blocking::get(format!("{mirror}/iso/latest/sha1sums.txt"))?; 138 | if rs.status().is_success() { 139 | for line in BufReader::new(rs).lines().filter_map(|result| result.ok()) { 140 | if line.ends_with(".iso") { 141 | let split: Vec<&str> = line.split_whitespace().collect(); 142 | if let [hash, filename] = split[..] { 143 | return Ok(IsoSource { 144 | url: format!("{mirror}/iso/latest/{filename}"), 145 | checksum: format!("sha1:{hash}"), 146 | }); 147 | } 148 | } 149 | } 150 | } 151 | } 152 | bail!("Failed to request latest ISO"); 153 | } 154 | 155 | #[cfg(test)] 156 | mod tests { 157 | use super::*; 158 | 159 | #[test] 160 | fn test_fetch_latest_iso() -> Result<()> { 161 | fetch_latest_iso(ArchMirrorlistProvisioner::default())?; 162 | Ok(()) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /goldboot/src/builder/os/debian/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/debian/icon.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/debian/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/debian/icon@2x.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/debian/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Result, bail}; 2 | use goldboot_image::ImageArch; 3 | use serde::{Deserialize, Serialize}; 4 | use smart_default::SmartDefault; 5 | use std::io::{BufRead, BufReader}; 6 | use strum::{Display, EnumIter, IntoEnumIterator}; 7 | use validator::Validate; 8 | 9 | use crate::{ 10 | builder::{ 11 | Builder, 12 | http::HttpServer, 13 | options::{ 14 | arch::Arch, hostname::Hostname, iso::Iso, size::Size, unix_account::RootPassword, 15 | }, 16 | qemu::{OsCategory, QemuBuilder}, 17 | }, 18 | cli::prompt::Prompt, 19 | enter, input, wait_screen, wait_screen_rect, 20 | }; 21 | 22 | use super::BuildImage; 23 | 24 | /// Debian is a Linux distribution composed of free and open-source software and 25 | /// optionally non-free firmware or software developed by the community-supported 26 | /// Debian Project. 27 | /// 28 | /// Upstream: https://www.debian.org 29 | /// Maintainer: cilki 30 | #[derive(Clone, Serialize, Deserialize, Validate, Debug, SmartDefault, goldboot_macros::Prompt)] 31 | pub struct Debian { 32 | #[default(Arch(ImageArch::Amd64))] 33 | pub arch: Arch, 34 | pub size: Size, 35 | pub edition: DebianEdition, 36 | #[serde(flatten)] 37 | pub hostname: Option, 38 | pub root_password: RootPassword, 39 | #[default(Iso { 40 | url: "http://example.com".parse().unwrap(), 41 | checksum: None, 42 | })] 43 | pub iso: Iso, 44 | } 45 | 46 | impl BuildImage for Debian { 47 | fn build(&self, worker: &Builder) -> Result<()> { 48 | let mut qemu = QemuBuilder::new(&worker, OsCategory::Linux) 49 | .vga("cirrus") 50 | .with_iso(&self.iso)? 51 | .prepare_ssh()? 52 | .start()?; 53 | 54 | // Start HTTP 55 | let http = HttpServer::new()? 56 | .file("preseed.cfg", include_bytes!("preseed.cfg"))? 57 | .serve(); 58 | 59 | // Send boot command 60 | #[rustfmt::skip] 61 | qemu.vnc.run(vec![ 62 | // Wait for boot 63 | wait_screen_rect!("f6852e8b6e072d15270b2b215bbada3da30fd733", 100, 100, 400, 400), 64 | // Trigger unattended install 65 | input!("aa"), 66 | // Wait for preseed URL to be prompted 67 | match self.edition { 68 | DebianEdition::Bullseye => todo!(), 69 | DebianEdition::Bookworm => wait_screen!("6ee7873098bceb5a2124db82dae6abdae214ce7e"), 70 | DebianEdition::Trixie => todo!(), 71 | DebianEdition::Sid => todo!(), 72 | }, 73 | enter!(format!("http://{}:{}/preseed.cfg", http.address, http.port)), 74 | // Wait for login prompt 75 | match self.edition { 76 | DebianEdition::Bullseye => todo!(), 77 | DebianEdition::Bookworm => wait_screen!("2eb1ef517849c86a322ba60bb05386decbf00ba5"), 78 | DebianEdition::Trixie => todo!(), 79 | DebianEdition::Sid => todo!(), 80 | }, 81 | // Login as root 82 | enter!("root"), 83 | enter!("r00tme"), 84 | ])?; 85 | 86 | // Wait for SSH 87 | let ssh = qemu.ssh("root")?; 88 | 89 | // Shutdown 90 | ssh.shutdown("poweroff")?; 91 | qemu.shutdown_wait()?; 92 | Ok(()) 93 | } 94 | } 95 | 96 | #[derive(Clone, Copy, Serialize, Deserialize, Debug, Default, EnumIter, Display)] 97 | pub enum DebianEdition { 98 | Bullseye, 99 | #[default] 100 | Bookworm, 101 | Trixie, 102 | Sid, 103 | } 104 | 105 | impl Prompt for DebianEdition { 106 | fn prompt(&mut self, builder: &Builder) -> Result<()> { 107 | let editions: Vec = DebianEdition::iter().collect(); 108 | let edition_index = dialoguer::Select::with_theme(&crate::cli::cmd::init::theme()) 109 | .with_prompt("Choose Debian edition") 110 | .default(0) 111 | .items(editions.iter()) 112 | .interact()?; 113 | 114 | *self = editions[edition_index]; 115 | Ok(()) 116 | } 117 | } 118 | 119 | /// Fetch the latest ISO 120 | pub fn fetch_debian_iso(edition: DebianEdition, arch: ImageArch) -> Result { 121 | let arch = match arch { 122 | ImageArch::Amd64 => "amd64", 123 | ImageArch::Arm64 => "arm64", 124 | ImageArch::I386 => "i386", 125 | _ => bail!("Unsupported architecture"), 126 | }; 127 | let version = match edition { 128 | DebianEdition::Bullseye => "archive/11.9.0", 129 | DebianEdition::Bookworm => "release/12.5.0", 130 | _ => bail!("Unsupported edition"), 131 | }; 132 | 133 | let rs = reqwest::blocking::get(format!( 134 | "https://cdimage.debian.org/cdimage/{version}/{arch}/iso-cd/SHA256SUMS" 135 | ))?; 136 | if rs.status().is_success() { 137 | for line in BufReader::new(rs).lines().filter_map(|result| result.ok()) { 138 | if line.ends_with(".iso") { 139 | let split: Vec<&str> = line.split_whitespace().collect(); 140 | if let [hash, filename] = split[..] { 141 | return Ok(Iso { 142 | url: format!( 143 | "https://cdimage.debian.org/cdimage/{version}/{arch}/iso-cd/{filename}" 144 | ) 145 | .parse() 146 | .unwrap(), 147 | checksum: Some(format!("sha256:{hash}")), 148 | }); 149 | } 150 | } 151 | } 152 | } 153 | bail!("Failed to request latest ISO"); 154 | } 155 | -------------------------------------------------------------------------------- /goldboot/src/builder/os/fedora/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/fedora/icon.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/fedora/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/fedora/icon@2x.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/linux_mint/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/linux_mint/icon.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/linux_mint/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/linux_mint/icon@2x.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/mac_os/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/mac_os/icon.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/mac_os/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/mac_os/icon@2x.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/mac_os/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{build::BuildWorker, cache::MediaCache, provisioners::*, qemu::QemuArgs, templates::*}; 2 | use serde::{Deserialize, Serialize}; 3 | use std::error::Error; 4 | use validator::Validate; 5 | 6 | //#[derive(rust_embed::RustEmbed)] 7 | //#[folder = "res/MacOs/"] 8 | //struct Resources; 9 | 10 | #[derive(Clone, Serialize, Deserialize, Debug)] 11 | pub enum MacOsRelease { 12 | Catalina, 13 | BigSur, 14 | Monterey, 15 | } 16 | 17 | #[derive(Clone, Serialize, Deserialize, Validate, Debug)] 18 | pub struct MacOsTemplate { 19 | pub id: TemplateId, 20 | pub release: MacOsRelease, 21 | 22 | pub iso: IsoSource, 23 | pub ansible: Option>, 24 | } 25 | 26 | impl Default for MacOsTemplate { 27 | fn default() -> Self { 28 | Self { 29 | id: TemplateId::MacOs, 30 | release: MacOsRelease::Monterey, 31 | provisioners: ProvisionersContainer { 32 | provisioners: Some(vec![ 33 | serde_json::to_value(ShellProvisioner::inline("/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"")).unwrap(), 34 | ]) 35 | }, 36 | version: MacOsVersion::Monterey, 37 | general: GeneralContainer{ 38 | base: TemplateBase::MacOs, 39 | storage_size: String::from("50 GiB"), 40 | .. Default::default() 41 | }, 42 | } 43 | } 44 | } 45 | 46 | impl Template for MacOsTemplate { 47 | fn build(&self, context: &BuildWorker) -> Result<()> { 48 | let mut qemuargs = QemuArgs::new(&context); 49 | 50 | // Copy OpenCore partition 51 | //if let Some(resource) = Resources::get("OpenCore.qcow2") { 52 | // std::fs::write(context.tmp.path().join("OpenCore.qcow2"), resource.data)?; 53 | //} 54 | 55 | // Convert dmg to img 56 | //qemu-img convert BaseSystem.dmg -O raw BaseSystem.img 57 | 58 | qemuargs.cpu = Some(format!("Penryn,kvm=on,vendor=GenuineIntel,+invtsc,vmware-cpuid-freq=on,+ssse3,+sse4.2,+popcnt,+avx,+aes,+xsave,+xsaveopt,check")); 59 | qemuargs.machine = format!("q35,accel=kvm"); 60 | qemuargs.smbios = Some(format!("type=2")); 61 | qemuargs.device.push(format!("ich9-ahci,id=sata")); 62 | qemuargs.device.push(format!("usb-ehci,id=ehci")); 63 | qemuargs.device.push(format!("nec-usb-xhci,id=xhci")); 64 | qemuargs.device.push(format!( 65 | "isa-applesmc,osk=ourhardworkbythesewordsguardedpleasedontsteal(c)AppleComputerInc" 66 | )); 67 | qemuargs.usbdevice.push(format!("keyboard")); 68 | qemuargs.usbdevice.push(format!("tablet")); 69 | qemuargs.global.push(format!("nec-usb-xhci.msi=off")); 70 | 71 | // Add boot partition 72 | qemuargs.drive.push(format!( 73 | "file={}/OpenCore.qcow2,id=OpenCore,if=none,format=qcow2", 74 | context.tmp.path().display() 75 | )); 76 | qemuargs 77 | .device 78 | .push(format!("ide-hd,bus=sata.2,drive=OpenCore")); 79 | 80 | // Add install media 81 | qemuargs.drive.push(format!( 82 | "file=/home/cilki/OSX-KVM/BaseSystem.img,id=InstallMedia,if=none,format=raw" 83 | )); 84 | qemuargs 85 | .device 86 | .push(format!("ide-hd,bus=sata.3,drive=InstallMedia")); 87 | 88 | // Add system drive 89 | qemuargs.drive.push(format!( 90 | "file={},id=System,if=none,cache=writeback,discard=ignore,format=qcow2", 91 | context.image_path, 92 | )); 93 | qemuargs 94 | .device 95 | .push(format!("ide-hd,bus=sata.4,drive=System")); 96 | 97 | // Start VM 98 | let mut qemu = qemuargs.start_process()?; 99 | 100 | // Send boot command 101 | match self.release { 102 | MacOsRelease::Monterey => { 103 | #[rustfmt::skip] 104 | qemu.vnc.boot_command(vec![ 105 | enter!(), 106 | enter!("diskutil eraseDisk APFS System disk0"), 107 | // Wait for "Select your region" screen 108 | wait_screen_rect!("fa1aeec4a3d4436d9bdd99345b29256ce4d141c8", 50, 0, 1024, 700), 109 | // Configure region 110 | enter!("United States"), tab!(), tab!(), enter!(), 111 | // ... 112 | // Configure ssh 113 | enter!("echo 'PermitRootLogin yes' >>/etc/ssh/sshd_config"), 114 | // Start sshd 115 | enter!("launchctl load -w /System/Library/LaunchDaemons/ssh.plist"), 116 | ])?; 117 | } 118 | _ => {} 119 | } 120 | 121 | // Wait for SSH 122 | let mut ssh = qemu.ssh_wait(context.ssh_port, "root", "root")?; 123 | 124 | // Run provisioners 125 | self.provisioners.run(&mut ssh)?; 126 | 127 | // Shutdown 128 | ssh.shutdown("shutdown -h now")?; 129 | qemu.shutdown_wait()?; 130 | Ok(()) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /goldboot/src/builder/os/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::builder::Builder; 2 | use crate::cli::prompt::Prompt; 3 | use anyhow::Result; 4 | use clap::ValueEnum; 5 | use enum_dispatch::enum_dispatch; 6 | use goldboot_image::ImageArch; 7 | #[cfg(feature = "config-python")] 8 | use pyo3::prelude::*; 9 | use serde::{Deserialize, Serialize}; 10 | use std::{fmt::Display, sync::OnceLock}; 11 | use strum::{EnumIter, IntoEnumIterator}; 12 | 13 | use alpine_linux::AlpineLinux; 14 | use arch_linux::ArchLinux; 15 | use debian::Debian; 16 | // use goldboot::Goldboot; 17 | use nix::Nix; 18 | use windows_10::Windows10; 19 | use windows_11::Windows11; 20 | 21 | pub mod alpine_linux; 22 | pub mod arch_linux; 23 | pub mod debian; 24 | // pub mod goldboot; 25 | pub mod nix; 26 | pub mod windows_10; 27 | pub mod windows_11; 28 | 29 | #[macro_export] 30 | macro_rules! size { 31 | ($os:expr) => { 32 | match $os.clone() { 33 | Os::AlpineLinux(inner) => inner.size, 34 | Os::ArchLinux(inner) => inner.size, 35 | Os::Debian(inner) => inner.size, 36 | Os::Nix(inner) => inner.size, 37 | Os::Windows10(inner) => inner.size, 38 | Os::Windows11(inner) => inner.size, 39 | } 40 | }; 41 | } 42 | 43 | /// "Building" is the process of generating an immutable goldboot image from raw 44 | /// configuration data. 45 | #[enum_dispatch(Os)] 46 | pub trait BuildImage { 47 | /// Build an image. 48 | fn build(&self, builder: &Builder) -> Result<()>; 49 | } 50 | 51 | // TODO Element? 52 | 53 | /// Represents a "base configuration" that users can modify and use to build 54 | /// images. 55 | #[enum_dispatch] 56 | #[derive(Clone, Serialize, Deserialize, Debug, EnumIter)] 57 | #[serde(tag = "os")] 58 | pub enum Os { 59 | AlpineLinux, 60 | ArchLinux, 61 | // Artix, 62 | // BedrockLinux, 63 | // CentOs, 64 | Debian, 65 | // ElementaryOs, 66 | // Fedora, 67 | // FreeBsd, 68 | // Gentoo, 69 | // Goldboot, 70 | // Haiku, 71 | // Kali, 72 | // LinuxMint, 73 | // MacOs, 74 | // Manjaro, 75 | // NetBsd, 76 | Nix, 77 | // OpenBsd, 78 | // OpenSuse, 79 | // Oracle, 80 | // Parrot, 81 | // PopOs, 82 | // Qubes, 83 | // RedHat, 84 | // RockyLinux, 85 | // Slackware, 86 | // SteamDeck, 87 | // SteamOs, 88 | // Tails, 89 | // TrueNas, 90 | // Ubuntu, 91 | // VoidLinux, 92 | Windows10, 93 | Windows11, 94 | // Windows7, 95 | // Zorin, 96 | } 97 | 98 | impl Os { 99 | /// Supported system architectures 100 | pub fn architectures(&self) -> Vec { 101 | match self { 102 | Os::AlpineLinux(_) => vec![ImageArch::Amd64, ImageArch::Arm64], 103 | Os::ArchLinux(_) => vec![ImageArch::Amd64], 104 | Os::Debian(_) => vec![ImageArch::Amd64, ImageArch::Arm64], 105 | // Os::Goldboot(_) => vec![ImageArch::Amd64, ImageArch::Arm64], 106 | Os::Nix(_) => vec![ImageArch::Amd64, ImageArch::Arm64], 107 | Os::Windows10(_) => vec![ImageArch::Amd64], 108 | Os::Windows11(_) => vec![ImageArch::Amd64], 109 | } 110 | } 111 | 112 | /// Whether the template can be combined with others in the same image 113 | pub fn alloy(&self) -> bool { 114 | false 115 | } 116 | 117 | // pub fn default_source 118 | } 119 | 120 | impl Display for Os { 121 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 122 | write!( 123 | f, 124 | "{}", 125 | match self { 126 | Os::AlpineLinux(_) => "AlpineLinux", 127 | Os::ArchLinux(_) => "ArchLinux", 128 | Os::Debian(_) => "Debian", 129 | // Os::Goldboot(_) => "Goldboot", 130 | Os::Nix(_) => "NixOS", 131 | Os::Windows10(_) => "Windows10", 132 | Os::Windows11(_) => "Windows11", 133 | } 134 | ) 135 | } 136 | } 137 | 138 | impl Default for Os { 139 | fn default() -> Self { 140 | Os::ArchLinux(ArchLinux::default()) 141 | } 142 | } 143 | 144 | #[cfg(feature = "config-python")] 145 | impl<'py> FromPyObject<'py> for Os { 146 | fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { 147 | pythonize::depythonize(ob) 148 | .map_err(|e| pyo3::exceptions::PyTypeError::new_err(e.to_string())) 149 | } 150 | } 151 | 152 | static VARIANTS: OnceLock> = OnceLock::new(); 153 | 154 | impl ValueEnum for Os { 155 | fn value_variants<'a>() -> &'a [Self] { 156 | VARIANTS.get_or_init(|| Os::iter().collect()) 157 | } 158 | 159 | fn to_possible_value(&self) -> Option { 160 | Some(clap::builder::PossibleValue::new( 161 | Into::::into(self.to_string()), 162 | )) 163 | } 164 | } 165 | 166 | // impl From for ElementHeader { 167 | // fn from(value: Element) -> ElementHeader { 168 | // todo!() 169 | // } 170 | // } 171 | -------------------------------------------------------------------------------- /goldboot/src/builder/os/nix/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use goldboot_image::ImageArch; 3 | use serde::{Deserialize, Serialize}; 4 | use smart_default::SmartDefault; 5 | use std::{collections::HashMap, path::PathBuf}; 6 | use validator::Validate; 7 | 8 | use crate::{ 9 | builder::{ 10 | Builder, 11 | options::{arch::Arch, iso::Iso, size::Size}, 12 | qemu::{OsCategory, QemuBuilder}, 13 | }, 14 | cli::prompt::Prompt, 15 | enter, wait, wait_screen_rect, 16 | }; 17 | 18 | use super::BuildImage; 19 | 20 | /// NixOS is a free and open source Linux distribution based on the Nix package 21 | /// manager. NixOS uses an immutable design and an atomic update model. Its use 22 | /// of a declarative configuration system allows reproducibility and 23 | /// portability. 24 | /// 25 | /// Upstream: https://www.nixos.org 26 | /// Maintainer: cilki 27 | #[derive(Clone, Serialize, Deserialize, Validate, Debug, SmartDefault, goldboot_macros::Prompt)] 28 | pub struct Nix { 29 | #[default(Arch(ImageArch::Amd64))] 30 | pub arch: Arch, 31 | pub size: Size, 32 | 33 | /// Path to /etc/nixos/configuration.nix 34 | #[default(ConfigurationPath("configuration.nix".parse().unwrap()))] 35 | pub configuration: ConfigurationPath, 36 | 37 | /// Path to /etc/nixos/hardware-configuration.nix 38 | pub hardware_configuration: Option, 39 | 40 | #[default(Iso { 41 | url: "http://example.com".parse().unwrap(), 42 | checksum: None, 43 | })] 44 | pub iso: Iso, 45 | } 46 | 47 | impl BuildImage for Nix { 48 | fn build(&self, worker: &Builder) -> Result<()> { 49 | let mut qemu = QemuBuilder::new(&worker, OsCategory::Linux) 50 | .with_iso(&self.iso)? 51 | // Add Nix config 52 | .drive_files(HashMap::from([( 53 | "configuration.nix".to_string(), 54 | self.configuration.load()?, 55 | )]))? 56 | .start()?; 57 | 58 | // Send boot command 59 | #[rustfmt::skip] 60 | qemu.vnc.run(vec![ 61 | // Initial wait 62 | wait!(30), 63 | // Wait for automatic login 64 | wait_screen_rect!("94a2520c082650cc01a4b5eac8719b697a4bbf63", 100, 100, 100, 100), 65 | enter!("sudo su -"), 66 | // Mount config partition and copy configuration.nix 67 | enter!("mkdir /goldboot"), 68 | enter!("mount /dev/vdb /goldboot"), 69 | enter!("cp /goldboot/configuration.nix /mnt/etc/nixos/configuration.nix"), 70 | enter!("umount /goldboot"), 71 | // Run install 72 | enter!("nixos-install"), 73 | ])?; 74 | 75 | // Shutdown 76 | qemu.shutdown_wait()?; 77 | Ok(()) 78 | } 79 | } 80 | 81 | #[derive(Clone, Serialize, Deserialize, Debug)] 82 | struct ConfigurationPath(PathBuf); 83 | 84 | impl ConfigurationPath { 85 | fn load(&self) -> Result> { 86 | if self.0.starts_with("http") { 87 | todo!() 88 | } 89 | 90 | let bytes = std::fs::read(&self.0)?; 91 | Ok(bytes) 92 | } 93 | } 94 | 95 | impl Prompt for ConfigurationPath { 96 | fn prompt(&mut self, _: &Builder) -> Result<()> { 97 | todo!() 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /goldboot/src/builder/os/open_suse/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/open_suse/icon.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/open_suse/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/open_suse/icon@2x.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/pop_os/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | build::BuildWorker, 3 | cache::{MediaCache, MediaFormat}, 4 | provisioners::*, 5 | qemu::QemuArgs, 6 | templates::*, 7 | }; 8 | use serde::{Deserialize, Serialize}; 9 | use std::error::Error; 10 | use validator::Validate; 11 | 12 | #[derive(Clone, Serialize, Deserialize, Default, Debug)] 13 | pub enum PopOsEdition { 14 | #[default] 15 | Amd, 16 | Nvidia, 17 | } 18 | 19 | #[derive(Clone, Serialize, Deserialize, Default, Debug)] 20 | pub enum PopOsRelease { 21 | #[serde(rename = "21.10")] 22 | #[default] 23 | V21_10, 24 | 25 | #[serde(rename = "22.04")] 26 | V22_04, 27 | } 28 | 29 | #[derive(Clone, Serialize, Deserialize, Validate, Debug)] 30 | pub struct PopOsTemplate { 31 | pub id: TemplateId, 32 | pub edition: PopOsEdition, 33 | pub release: PopOsRelease, 34 | 35 | pub iso: IsoSource, 36 | pub hostname: HostnameProvisioner, 37 | 38 | pub username: String, 39 | 40 | pub password: String, 41 | 42 | pub root_password: String, 43 | 44 | pub ansible: Option>, 45 | } 46 | 47 | impl Default for PopOsTemplate { 48 | fn default() -> Self { 49 | Self { 50 | id: TemplateId::PopOs, 51 | edition: PopOsEdition::Amd, 52 | release: PopOsRelease::V21_10, 53 | username: whoami::username(), 54 | password: String::from("88Password;"), 55 | root_password: String::from("root"), 56 | iso: IsoContainer { 57 | url: String::from("https://pop-iso.sfo2.cdn.digitaloceanspaces.com/21.10/amd64/intel/7/pop-os_21.10_amd64_intel_7.iso"), 58 | checksum: String::from("sha256:93e8d3977d9414d7f32455af4fa38ea7a71170dc9119d2d1f8e1fba24826fae2"), 59 | }, 60 | general: GeneralContainer{ 61 | base: TemplateBase::PopOs, 62 | storage_size: String::from("15 GiB"), 63 | .. Default::default() 64 | }, 65 | provisioners: ProvisionersContainer::default(), 66 | } 67 | } 68 | } 69 | 70 | impl Template for PopOsTemplate { 71 | fn build(&self, context: &BuildWorker) -> Result<()> { 72 | let mut qemuargs = QemuArgs::new(&context); 73 | 74 | qemuargs.drive.push(format!( 75 | "file={},if=virtio,cache=writeback,discard=ignore,format=qcow2", 76 | context.image_path 77 | )); 78 | qemuargs.drive.push(format!( 79 | "file={},media=cdrom", 80 | MediaCache::get(self.iso.url.clone(), &self.iso.checksum, MediaFormat::Iso)? 81 | )); 82 | 83 | // Start VM 84 | let mut qemu = qemuargs.start_process()?; 85 | 86 | // Send boot command 87 | qemu.vnc.boot_command(vec![ 88 | // Wait for boot 89 | wait!(120), 90 | // Select language: English 91 | enter!(), 92 | // Select location: United States 93 | enter!(), 94 | // Select keyboard layout: US 95 | enter!(), 96 | enter!(), 97 | // Select clean install 98 | spacebar!(), 99 | enter!(), 100 | // Select disk 101 | spacebar!(), 102 | enter!(), 103 | // Configure username 104 | enter!(self.username), 105 | // Configure password 106 | input!(self.password), 107 | tab!(), 108 | enter!(self.password), 109 | // Enable disk encryption 110 | enter!(), 111 | // Wait for installation (avoiding screen timeouts) 112 | wait!(250), 113 | spacebar!(), 114 | wait!(250), 115 | // Reboot 116 | enter!(), 117 | wait!(30), 118 | // Unlock disk 119 | enter!(self.password), 120 | wait!(30), 121 | // Login 122 | enter!(), 123 | enter!(self.password), 124 | wait!(60), 125 | // Open terminal 126 | leftSuper!(), 127 | enter!("terminal"), 128 | // Root login 129 | enter!("sudo su -"), 130 | enter!(self.password), 131 | // Change root password 132 | enter!("passwd"), 133 | enter!(self.root_password), 134 | enter!(self.root_password), 135 | // Update package cache 136 | enter!("apt update"), 137 | wait!(30), 138 | // Install sshd 139 | enter!("apt install -y openssh-server"), 140 | wait!(30), 141 | // Configure sshd 142 | enter!("echo 'PermitRootLogin yes' >>/etc/ssh/sshd_config"), 143 | // Start sshd 144 | enter!("systemctl restart sshd"), 145 | ])?; 146 | 147 | // Wait for SSH 148 | let mut ssh = qemu.ssh_wait(context.ssh_port, "root", &self.root_password)?; 149 | 150 | // Run provisioners 151 | self.provisioners.run(&mut ssh)?; 152 | 153 | // Shutdown 154 | ssh.shutdown("poweroff")?; 155 | qemu.shutdown_wait()?; 156 | Ok(()) 157 | } 158 | 159 | fn general(&self) -> GeneralContainer { 160 | self.general.clone() 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /goldboot/src/builder/os/slackware/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/slackware/icon.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/slackware/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/slackware/icon@2x.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/steam_deck/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | build::BuildWorker, 3 | cache::{MediaCache, MediaFormat}, 4 | provisioners::*, 5 | qemu::QemuArgs, 6 | templates::*, 7 | }; 8 | use serde::{Deserialize, Serialize}; 9 | use std::error::Error; 10 | use validator::Validate; 11 | 12 | #[derive(Clone, Serialize, Deserialize, Validate, Debug)] 13 | pub struct SteamDeckTemplate { 14 | pub id: TemplateId, 15 | pub recovery_url: String, 16 | 17 | pub recovery_checksum: String, 18 | } 19 | 20 | impl Default for SteamDeckTemplate { 21 | fn default() -> Self { 22 | Self { 23 | recovery_url: String::from( 24 | "https://steamdeck-images.steamos.cloud/recovery/steamdeck-recovery-1.img.bz2", 25 | ), 26 | recovery_checksum: String::from( 27 | "sha256:5086bcc4fe0fb230dff7265ff6a387dd00045e3d9ae6312de72003e1e82d4526", 28 | ), 29 | general: GeneralContainer { 30 | base: TemplateBase::SteamDeck, 31 | storage_size: String::from("15 GiB"), 32 | ..Default::default() 33 | }, 34 | } 35 | } 36 | } 37 | 38 | impl Template for SteamDeckTemplate { 39 | fn build(&self, context: &BuildWorker) -> Result<()> { 40 | let mut qemuargs = QemuArgs::new(&context); 41 | 42 | qemuargs.drive.push(format!( 43 | "file={},format=raw", 44 | MediaCache::get( 45 | self.recovery_url.clone(), 46 | &self.recovery_checksum, 47 | MediaFormat::Bzip2 48 | )? 49 | )); 50 | qemuargs.drive.push(format!( 51 | "file={},if=none,cache=writeback,discard=ignore,format=qcow2,id=nvme", 52 | context.image_path 53 | )); 54 | 55 | // Make the storage looks like an nvme drive 56 | qemuargs 57 | .device 58 | .push(String::from("nvme,serial=cafebabe,drive=nvme")); 59 | 60 | // Start VM 61 | let mut qemu = qemuargs.start_process()?; 62 | 63 | // Send boot command 64 | #[rustfmt::skip] 65 | qemu.vnc.boot_command(vec![ 66 | // Initial wait 67 | wait!(20), 68 | // Wait for login 69 | wait_screen_rect!("ba99ede257ef4ee2056a328eb3feffa65e821e0d", 0, 0, 1024, 700), 70 | // Open terminal 71 | leftSuper!(), enter!("terminal"), 72 | // Disable Zenity prompt 73 | enter!("sed -i '/zenity/d' ./tools/repair_device.sh"), 74 | // Poweroff instead of reboot on completion 75 | enter!("sed -i 's/systemctl reboot/systemctl poweroff/' ./tools/repair_device.sh"), 76 | // Begin reimage 77 | enter!("./tools/repair_reimage.sh"), 78 | ])?; 79 | 80 | // Wait for shutdown 81 | qemu.shutdown_wait()?; 82 | 83 | Ok(()) 84 | } 85 | 86 | fn general(&self) -> GeneralContainer { 87 | self.general.clone() 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /goldboot/src/builder/os/steam_os/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | build::BuildWorker, 3 | cache::{MediaCache, MediaFormat}, 4 | provisioners::*, 5 | qemu::QemuArgs, 6 | templates::*, 7 | }; 8 | use serde::{Deserialize, Serialize}; 9 | use std::error::Error; 10 | use validator::Validate; 11 | 12 | #[derive(Clone, Serialize, Deserialize, Debug)] 13 | pub enum SteamOsVersion { 14 | Brewmaster2_195, 15 | } 16 | 17 | #[derive(Clone, Serialize, Deserialize, Validate, Debug)] 18 | pub struct SteamOsTemplate { 19 | pub id: TemplateId, 20 | pub version: SteamOsVersion, 21 | 22 | pub iso: IsoSource, 23 | pub hostname: HostnameProvisioner, 24 | 25 | pub root_password: String, 26 | 27 | pub ansible: Option>, 28 | } 29 | 30 | impl Default for SteamOsTemplate { 31 | fn default() -> Self { 32 | Self { 33 | iso: IsoContainer { 34 | url: String::from( 35 | "https://repo.steampowered.com/download/brewmaster/2.195/SteamOSDVD.iso", 36 | ), 37 | checksum: String::from("sha512:0ce55048d2c5e8a695f309abe22303dded003c93386ad28c6daafc977b3d5b403ed94d7c38917c8c837a2b1fe560184cf3cc12b9f2c4069fd70ed0deab47eb7c"), 38 | }, 39 | root_password: String::from("root"), 40 | version: SteamOsVersion::Brewmaster2_195, 41 | general: GeneralContainer{ 42 | base: TemplateBase::SteamOs, 43 | storage_size: String::from("15 GiB"), 44 | .. Default::default() 45 | }, 46 | provisioners: ProvisionersContainer::default(), 47 | } 48 | } 49 | } 50 | 51 | impl Template for SteamOsTemplate { 52 | fn build(&self, context: &BuildWorker) -> Result<()> { 53 | let mut qemuargs = QemuArgs::new(&context); 54 | 55 | qemuargs.drive.push(format!( 56 | "file={},if=virtio,cache=writeback,discard=ignore,format=qcow2", 57 | context.image_path 58 | )); 59 | qemuargs.drive.push(format!( 60 | "file={},media=cdrom", 61 | MediaCache::get(self.iso.url.clone(), &self.iso.checksum, MediaFormat::Iso)? 62 | )); 63 | 64 | // Start VM 65 | let mut qemu = qemuargs.start_process()?; 66 | 67 | // Send boot command 68 | #[rustfmt::skip] 69 | qemu.vnc.boot_command(vec![ 70 | // Wait for bootloader 71 | wait_screen!("28fe084e08242584908114a5d21960fdf072adf9"), 72 | // Start automated install 73 | enter!(), 74 | // Wait for completion 75 | wait_screen!(""), 76 | ])?; 77 | 78 | // Wait for SSH 79 | let mut ssh = qemu.ssh_wait(context.ssh_port, "root", &self.root_password)?; 80 | 81 | // Run provisioners 82 | self.provisioners.run(&mut ssh)?; 83 | 84 | // Shutdown 85 | ssh.shutdown("poweroff")?; 86 | qemu.shutdown_wait()?; 87 | Ok(()) 88 | } 89 | 90 | fn general(&self) -> GeneralContainer { 91 | self.general.clone() 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /goldboot/src/builder/os/ubuntu/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/ubuntu/icon.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/ubuntu/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/ubuntu/icon@2x.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/ubuntu/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | build::BuildWorker, 3 | cache::{MediaCache, MediaFormat}, 4 | provisioners::*, 5 | qemu::QemuArgs, 6 | templates::*, 7 | }; 8 | use serde::{Deserialize, Serialize}; 9 | use std::error::Error; 10 | use strum::{Display, EnumIter, IntoEnumIterator}; 11 | use validator::Validate; 12 | 13 | #[derive(Clone, Serialize, Deserialize, Debug, EnumIter)] 14 | pub enum UbuntuRelease { 15 | Jammy, 16 | Impish, 17 | Hirsute, 18 | Groovy, 19 | Focal, 20 | Eoan, 21 | Disco, 22 | Cosmic, 23 | Bionic, 24 | Artful, 25 | } 26 | 27 | impl Display for UbuntuRelease { 28 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 29 | write!( 30 | f, 31 | "{}", 32 | match &self { 33 | UbuntuRelease::Jammy => "22.04 LTS (Jammy Jellyfish)", 34 | UbuntuRelease::Impish => "21.10 (Impish Indri)", 35 | UbuntuRelease::Hirsute => "21.04 (Hirsute Hippo)", 36 | UbuntuRelease::Groovy => "20.10 (Groovy Gorilla)", 37 | } 38 | ) 39 | } 40 | } 41 | 42 | #[derive(Clone, Serialize, Deserialize, Debug, EnumIter, Display)] 43 | pub enum UbuntuEdition { 44 | Server, 45 | Desktop, 46 | } 47 | 48 | impl Prompt for UbuntuEdition {} 49 | 50 | /// Ubuntu is a Linux distribution derived from Debian and composed mostly of free 51 | /// and open-source software. 52 | /// 53 | /// Upstream: https://ubuntu.com 54 | /// Maintainer: cilki 55 | #[derive(Clone, Serialize, Deserialize, Validate, Debug)] 56 | pub struct Ubuntu { 57 | pub edition: UbuntuEdition, 58 | pub release: UbuntuRelease, 59 | 60 | pub source: UbuntuSource, 61 | pub provisioners: Option>, 62 | } 63 | 64 | pub enum UbuntuSource { 65 | Iso(IsoSource), 66 | } 67 | 68 | pub enum UbuntuProvisioner { 69 | Ansible(AnsibleProvisioner), 70 | Hostname(HostnameProvisoner), 71 | } 72 | 73 | impl Default for UbuntuTemplate { 74 | fn default() -> Self { 75 | Self { 76 | edition: UbuntuEdition::Desktop, 77 | release: UbuntuRelease::Jammy, 78 | provisioners: None, 79 | } 80 | } 81 | } 82 | 83 | impl Template for UbuntuTemplate { 84 | fn build(&self, context: &BuildWorker) -> Result<()> { 85 | let mut qemuargs = QemuArgs::new(&context); 86 | 87 | qemuargs.drive.push(format!( 88 | "file={},if=virtio,cache=writeback,discard=ignore,format=qcow2", 89 | context.image_path 90 | )); 91 | qemuargs.drive.push(format!( 92 | "file={},media=cdrom", 93 | MediaCache::get(self.iso.url.clone(), &self.iso.checksum, MediaFormat::Iso)? 94 | )); 95 | 96 | // Start VM 97 | let mut qemu = qemuargs.start_process()?; 98 | 99 | // Send boot command 100 | #[rustfmt::skip] 101 | qemu.vnc.boot_command(vec![ 102 | ])?; 103 | 104 | // Wait for SSH 105 | let mut ssh = qemu.ssh_wait(context.ssh_port, "root", &self.root_password)?; 106 | 107 | // Run provisioners 108 | self.provisioners.run(&mut ssh)?; 109 | 110 | // Shutdown 111 | ssh.shutdown("poweroff")?; 112 | qemu.shutdown_wait()?; 113 | Ok(()) 114 | } 115 | 116 | fn general(&self) -> GeneralContainer { 117 | self.general.clone() 118 | } 119 | } 120 | 121 | impl Prompt for Ubuntu { 122 | fn prompt(&mut self, _builder: &Foundry) -> Result<()> { 123 | // Prompt edition 124 | { 125 | let editions: Vec = UbuntuEdition::iter().collect(); 126 | let edition_index = dialoguer::Select::with_theme(&crate::cli::cmd::init::theme()) 127 | .with_prompt("Choose Ubuntu edition") 128 | .default(0) 129 | .items(&editions) 130 | .interact()?; 131 | 132 | self.edition = editions[edition_index]; 133 | } 134 | 135 | // Prompt release 136 | { 137 | let releases: Vec = UbuntuRelease::iter().collect(); 138 | let release_index = dialoguer::Select::with_theme(&crate::cli::cmd::init::theme()) 139 | .with_prompt("Choose Ubuntu release") 140 | .default(0) 141 | .items(&releases) 142 | .interact()?; 143 | 144 | self.release = releases[release_index]; 145 | } 146 | 147 | Ok(()) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /goldboot/src/builder/os/windows_10/configure_winrm.ps1: -------------------------------------------------------------------------------- 1 | # Supress network location Prompt 2 | New-Item -Path "HKLM:\SYSTEM\CurrentControlSet\Control\Network\NewNetworkWindowOff" -Force 3 | 4 | # Set network to private 5 | $ifaceinfo = Get-NetConnectionProfile 6 | Set-NetConnectionProfile -InterfaceIndex $ifaceinfo.InterfaceIndex -NetworkCategory Private 7 | 8 | # Configure WinRM itself 9 | winrm quickconfig -q 10 | winrm s "winrm/config" '@{MaxTimeoutms="1800000"}' 11 | winrm s "winrm/config/winrs" '@{MaxMemoryPerShellMB="2048"}' 12 | winrm s "winrm/config/service" '@{AllowUnencrypted="true"}' 13 | winrm s "winrm/config/service/auth" '@{Basic="true"}' 14 | 15 | # Enable the WinRM Firewall rule, which will likely already be enabled due to the 'winrm quickconfig' command above 16 | Enable-NetFirewallRule -DisplayName "Windows Remote Management (HTTP-In)" 17 | 18 | exit 0 -------------------------------------------------------------------------------- /goldboot/src/builder/os/windows_10/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/windows_10/icon.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/windows_10/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/os/windows_10/icon@2x.png -------------------------------------------------------------------------------- /goldboot/src/builder/os/windows_7/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::provisioners::*; 2 | use serde::{Deserialize, Serialize}; 3 | use std::error::Error; 4 | use validator::Validate; 5 | 6 | #[derive(Clone, Serialize, Deserialize, Validate, Debug)] 7 | pub struct Windows7Template { 8 | pub id: TemplateId, 9 | 10 | pub iso: IsoSource, 11 | pub ansible: Option>, 12 | } 13 | -------------------------------------------------------------------------------- /goldboot/src/builder/ovmf/aarch64.fd.zst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/ovmf/aarch64.fd.zst -------------------------------------------------------------------------------- /goldboot/src/builder/ovmf/i386.fd.zst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/ovmf/i386.fd.zst -------------------------------------------------------------------------------- /goldboot/src/builder/ovmf/mod.rs: -------------------------------------------------------------------------------- 1 | // UEFI firmwares for various platforms. We include them here to avoid having 2 | // to depend on one provided by the system. 3 | 4 | use anyhow::Result; 5 | use anyhow::bail; 6 | use goldboot_image::ImageArch; 7 | use std::path::Path; 8 | use std::path::PathBuf; 9 | 10 | // TODO use build script to download these from: https://github.com/retrage/edk2-nightly 11 | #[cfg(feature = "include_ovmf")] 12 | pub fn write(arch: ImageArch, path: impl AsRef) -> Result<()> { 13 | match &arch { 14 | ImageArch::Amd64 => { 15 | std::fs::write( 16 | &path, 17 | zstd::decode_all(std::io::Cursor::new(include_bytes!("x86_64.fd.zst")))?, 18 | )?; 19 | } 20 | ImageArch::I386 => { 21 | std::fs::write( 22 | &path, 23 | zstd::decode_all(std::io::Cursor::new(include_bytes!("i386.fd.zst")))?, 24 | )?; 25 | } 26 | ImageArch::Arm64 => { 27 | std::fs::write( 28 | &path, 29 | zstd::decode_all(std::io::Cursor::new(include_bytes!("aarch64.fd.zst")))?, 30 | )?; 31 | } 32 | _ => bail!("Unsupported architecture"), 33 | } 34 | Ok(()) 35 | } 36 | 37 | pub fn find() -> Option { 38 | // TODO 39 | None 40 | } 41 | -------------------------------------------------------------------------------- /goldboot/src/builder/ovmf/x86_64.fd.zst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/builder/ovmf/x86_64.fd.zst -------------------------------------------------------------------------------- /goldboot/src/builder/sources.rs: -------------------------------------------------------------------------------- 1 | use crate::cli::progress::ProgressBar; 2 | use anyhow::Result; 3 | use anyhow::anyhow; 4 | use anyhow::bail; 5 | use sha1::{Digest, Sha1}; 6 | use sha2::{Sha256, Sha512}; 7 | use std::{ 8 | fs::File, 9 | path::{Path, PathBuf}, 10 | }; 11 | use tracing::{debug, info}; 12 | 13 | /// Simple cache for source installation media like ISOs. 14 | pub struct SourceCache { 15 | /// Cache location on disk 16 | pub directory: PathBuf, 17 | } 18 | 19 | impl SourceCache { 20 | /// Get the default platform-dependent source cache. 21 | pub fn default() -> Result { 22 | let directory = if cfg!(target_os = "linux") { 23 | PathBuf::from(format!( 24 | "/home/{}/.cache/goldboot/sources", 25 | whoami::username() 26 | )) 27 | } else if cfg!(target_os = "macos") { 28 | PathBuf::from(format!( 29 | "/Users/{}/.cache/goldboot/sources", 30 | whoami::username() 31 | )) 32 | } else if cfg!(target_os = "windows") { 33 | PathBuf::from(format!( 34 | "C:/Users/{}/AppData/Local/goldboot/cache/sources", 35 | whoami::username() 36 | )) 37 | } else { 38 | bail!("Unsupported platform"); 39 | }; 40 | 41 | // Make sure it exists before we return 42 | std::fs::create_dir_all(&directory)?; 43 | Ok(Self { directory }) 44 | } 45 | 46 | pub fn get(&self, url: String, checksum: Option) -> Result { 47 | let id = hex::encode(Sha1::new().chain_update(&url).finalize()); 48 | let path = self.directory.join(id); 49 | 50 | // Delete file if the checksum doesn't match 51 | if let Some(checksum) = checksum.clone() { 52 | if path.is_file() { 53 | if !Self::verify_checksum(path.to_string_lossy().to_string(), checksum.as_str()) 54 | .is_ok() 55 | { 56 | info!("Deleting corrupt cached file"); 57 | std::fs::remove_file(&path)?; 58 | } 59 | } 60 | } 61 | 62 | if !path.is_file() { 63 | // Check for local URL 64 | if !url.starts_with("http") && Path::new(&url).is_file() { 65 | return Ok(url); 66 | } 67 | 68 | // Try to download it 69 | let mut rs = reqwest::blocking::get(&url)?; 70 | if rs.status().is_success() { 71 | let length = rs 72 | .content_length() 73 | .ok_or_else(|| anyhow!("Failed to get content length"))?; 74 | let mut file = File::create(&path)?; 75 | 76 | info!("Saving install media"); 77 | ProgressBar::Download.copy(&mut rs, &mut file, length)?; 78 | } else { 79 | bail!("Failed to download"); 80 | } 81 | 82 | if let Some(checksum) = checksum { 83 | Self::verify_checksum(path.to_string_lossy().to_string(), checksum.as_str())?; 84 | } 85 | } 86 | 87 | Ok(path.to_string_lossy().to_string()) 88 | } 89 | 90 | fn verify_checksum(path: String, checksum: &str) -> Result<()> { 91 | // "None" shortcut 92 | if checksum == "none" { 93 | return Ok(()); 94 | } 95 | 96 | let c: Vec<&str> = checksum.split(":").collect(); 97 | if c.len() != 2 { 98 | bail!("Invalid checksum: {}", checksum); 99 | } 100 | 101 | let mut file = File::open(&path)?; 102 | 103 | let hash = match c[0] { 104 | "sha1" | "SHA1" => { 105 | info!("Computing SHA1 checksum"); 106 | let mut hasher = Sha1::new(); 107 | ProgressBar::Hash.copy(&mut file, &mut hasher, std::fs::metadata(&path)?.len())?; 108 | hex::encode(hasher.finalize()) 109 | } 110 | "sha256" | "SHA256" => { 111 | info!("Computing SHA256 checksum"); 112 | let mut hasher = Sha256::new(); 113 | ProgressBar::Hash.copy(&mut file, &mut hasher, std::fs::metadata(&path)?.len())?; 114 | hex::encode(hasher.finalize()) 115 | } 116 | "sha512" | "SHA512" => { 117 | info!("Computing SHA512 checksum"); 118 | let mut hasher = Sha512::new(); 119 | ProgressBar::Hash.copy(&mut file, &mut hasher, std::fs::metadata(&path)?.len())?; 120 | hex::encode(hasher.finalize()) 121 | } 122 | _ => bail!("Unsupported hash"), 123 | }; 124 | 125 | debug!("Computed: {}", &hash); 126 | debug!("Expected: {}", &c[1]); 127 | 128 | if hash != c[1] { 129 | bail!("Hash mismatch"); 130 | } 131 | 132 | Ok(()) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /goldboot/src/builder/sources/buildroot.rs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /goldboot/src/cli/cmd/build.rs: -------------------------------------------------------------------------------- 1 | use crate::builder::Builder; 2 | use crate::config::ConfigPath; 3 | use std::process::ExitCode; 4 | use tracing::{debug, error}; 5 | use validator::Validate; 6 | 7 | pub fn run(cmd: super::Commands) -> ExitCode { 8 | match cmd.clone() { 9 | super::Commands::Build { 10 | read_password, 11 | path, 12 | .. 13 | } => { 14 | let config_path = match ConfigPath::from_dir(path) { 15 | Some(p) => { 16 | debug!("Loading config from {}", p); 17 | p 18 | } 19 | _ => { 20 | error!("Failed to find config file"); 21 | return ExitCode::FAILURE; 22 | } 23 | }; 24 | 25 | // Load config from current directory 26 | let elements = match config_path.load() { 27 | Ok(elements) => { 28 | debug!("Loaded: {:#?}", &elements); 29 | elements 30 | } 31 | Err(error) => { 32 | error!("Failed to load config: {:?}", error); 33 | return ExitCode::FAILURE; 34 | } 35 | }; 36 | 37 | let mut builder = Builder::new(elements); 38 | 39 | // Include the encryption password if provided 40 | if read_password { 41 | print!("Enter password: "); 42 | let mut password = String::new(); 43 | std::io::stdin().read_line(&mut password).unwrap(); 44 | // config.password = Some(password); 45 | } else if let Ok(_password) = std::env::var("GOLDBOOT_PASSWORD") { 46 | // Wipe out the value since we no longer need it 47 | unsafe { 48 | std::env::set_var("GOLDBOOT_PASSWORD", ""); 49 | } 50 | // config.password = Some(password); 51 | } 52 | 53 | // Fully verify config before proceeding 54 | match builder.validate() { 55 | Err(err) => { 56 | error!(error = ?err, "Failed to validate config file"); 57 | return ExitCode::FAILURE; 58 | } 59 | _ => debug!("Validated config file"), 60 | }; 61 | 62 | // Run the build finally 63 | match builder.run(cmd) { 64 | Err(err) => { 65 | error!(error = ?err, "Failed to build image"); 66 | ExitCode::FAILURE 67 | } 68 | _ => ExitCode::SUCCESS, 69 | } 70 | } 71 | _ => panic!(), 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /goldboot/src/cli/cmd/deploy.rs: -------------------------------------------------------------------------------- 1 | use console::Style; 2 | use dialoguer::{Confirm, theme::ColorfulTheme}; 3 | use goldboot_image::ImageHandle; 4 | use std::{path::Path, process::ExitCode}; 5 | use tracing::error; 6 | 7 | use crate::{cli::progress::ProgressBar, library::ImageLibrary}; 8 | 9 | pub fn run(cmd: super::Commands) -> ExitCode { 10 | match cmd { 11 | super::Commands::Deploy { 12 | image, 13 | output, 14 | confirm, 15 | } => { 16 | let theme = ColorfulTheme { 17 | values_style: Style::new().yellow().dim(), 18 | ..ColorfulTheme::default() 19 | }; 20 | 21 | let mut image_handle = if Path::new(&image).exists() { 22 | match ImageHandle::open(&image) { 23 | Ok(image_handle) => image_handle, 24 | Err(_) => return ExitCode::FAILURE, 25 | } 26 | } else { 27 | match ImageLibrary::find_by_id(&image) { 28 | Ok(image_handle) => image_handle, 29 | Err(_) => return ExitCode::FAILURE, 30 | } 31 | }; 32 | if image_handle.load(None).is_err() { 33 | return ExitCode::FAILURE; 34 | } 35 | 36 | if Path::new(&output).exists() && !confirm { 37 | if !Confirm::with_theme(&theme) 38 | .with_prompt("Do you want to continue?") 39 | .interact() 40 | .unwrap() 41 | { 42 | std::process::exit(0); 43 | } 44 | } 45 | 46 | // TODO special case for GBL; select images to include 47 | 48 | match image_handle.write(output, ProgressBar::Write.new_empty()) { 49 | Err(err) => { 50 | error!(error = ?err, "Failed to write image"); 51 | ExitCode::FAILURE 52 | } 53 | _ => ExitCode::SUCCESS, 54 | } 55 | } 56 | _ => panic!(), 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /goldboot/src/cli/cmd/image.rs: -------------------------------------------------------------------------------- 1 | use std::process::ExitCode; 2 | 3 | use crate::library::ImageLibrary; 4 | use chrono::TimeZone; 5 | 6 | use ubyte::ToByteUnit; 7 | 8 | pub fn run(cmd: super::Commands) -> ExitCode { 9 | match cmd { 10 | super::Commands::Image { command } => match &command { 11 | super::ImageCommands::List {} => { 12 | let images = ImageLibrary::find_all().unwrap(); 13 | 14 | println!( 15 | "Image Name Image Size Build Date Image ID Description" 16 | ); 17 | for image in images { 18 | println!( 19 | "{:15} {:12} {:31} {:12} {}", 20 | todo!(), 21 | image.primary_header.size.bytes().to_string(), 22 | chrono::Utc 23 | .timestamp(image.primary_header.timestamp as i64, 0) 24 | .to_rfc2822(), 25 | &image.id[0..12], 26 | "TODO", 27 | ); 28 | } 29 | ExitCode::SUCCESS 30 | } 31 | super::ImageCommands::Info { image } => { 32 | if let Some(image) = image { 33 | let _image = ImageLibrary::find_by_id(image).unwrap(); 34 | // TODO 35 | } 36 | 37 | ExitCode::SUCCESS 38 | } 39 | }, 40 | _ => panic!(), 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /goldboot/src/cli/cmd/init.rs: -------------------------------------------------------------------------------- 1 | use clap::ValueEnum; 2 | use console::Style; 3 | use dialoguer::{Confirm, Input, Password, Select, theme::ColorfulTheme}; 4 | use goldboot_image::ImageArch; 5 | use std::process::ExitCode; 6 | use strum::IntoEnumIterator; 7 | use tracing::{error, info}; 8 | 9 | use crate::{ 10 | builder::{Builder, os::Os}, 11 | cli::prompt::Prompt, 12 | config::ConfigPath, 13 | }; 14 | 15 | fn print_banner() { 16 | if console::colors_enabled() { 17 | let style = Style::new().yellow(); 18 | 19 | println!("{}", ""); 20 | for line in fossable::goldboot_word() { 21 | println!(" {}", style.apply_to(line)); 22 | } 23 | println!("{}", ""); 24 | } 25 | } 26 | 27 | /// Get the current theme for prompts. 28 | pub fn theme() -> ColorfulTheme { 29 | ColorfulTheme { 30 | values_style: Style::new().yellow().dim(), 31 | ..ColorfulTheme::default() 32 | } 33 | } 34 | 35 | pub fn run(cmd: super::Commands) -> ExitCode { 36 | match cmd { 37 | super::Commands::Init { 38 | name, 39 | os, 40 | format, 41 | mimic_hardware: _, 42 | } => { 43 | let mut config_path = ConfigPath::from_dir(".").unwrap_or(format); 44 | let mut builder = Builder::new(os); 45 | 46 | if builder.elements.len() == 0 { 47 | // If no OS was given, begin interactive config 48 | print_banner(); 49 | 50 | let theme = theme(); 51 | 52 | println!("Get ready to create a new image configuration!"); 53 | println!("(it can be further edited later)"); 54 | println!(); 55 | 56 | // Prompt config format 57 | { 58 | let formats: &[ConfigPath] = ConfigPath::value_variants(); 59 | let choice_index = Select::with_theme(&theme) 60 | .with_prompt("Config format?") 61 | .default(0) 62 | .items(formats.iter()) 63 | .interact() 64 | .unwrap(); 65 | config_path = formats[choice_index].clone(); 66 | } 67 | 68 | // Prompt image name 69 | // builder.name = Input::with_theme(&theme) 70 | // .with_prompt("Image name?") 71 | // .default( 72 | // std::env::current_dir() 73 | // .unwrap() 74 | // .file_name() 75 | // .unwrap() 76 | // .to_str() 77 | // .unwrap() 78 | // .to_string(), 79 | // ) 80 | // .interact() 81 | // .unwrap(); 82 | 83 | // Prompt image architecture 84 | let arch = { 85 | let architectures: Vec = ImageArch::iter().collect(); 86 | let choice_index = Select::with_theme(&theme) 87 | .with_prompt("Image architecture?") 88 | .default(0) 89 | .items(&architectures) 90 | .interact() 91 | .unwrap(); 92 | 93 | architectures[choice_index] 94 | }; 95 | 96 | // Prompt OS 97 | loop { 98 | // Find operating systems suitable for the architecture 99 | let mut supported_os: Vec = Os::iter() 100 | .filter(|os| os.architectures().contains(&arch)) 101 | .filter(|os| builder.elements.len() == 0 || os.alloy()) 102 | .collect(); 103 | 104 | let choice_index = Select::with_theme(&theme) 105 | .with_prompt("Operating system?") 106 | .items(&supported_os) 107 | .interact() 108 | .unwrap(); 109 | 110 | let os = &mut supported_os[choice_index]; 111 | 112 | if Confirm::with_theme(&theme) 113 | .with_prompt("Edit OS configuration?") 114 | .interact() 115 | .unwrap() 116 | { 117 | // TODO show some kind of banner 118 | os.prompt(&builder).unwrap(); 119 | } 120 | 121 | builder.elements.push(os.clone()); 122 | 123 | if !os.alloy() 124 | || !Confirm::with_theme(&theme) 125 | .with_prompt("Create an alloy image (multiboot)?") 126 | .interact() 127 | .unwrap() 128 | { 129 | break; 130 | } 131 | } 132 | } 133 | 134 | // Finally write out the config 135 | match config_path.write(&builder.elements) { 136 | Err(err) => { 137 | error!(error = ?err, "Failed to write config file"); 138 | ExitCode::FAILURE 139 | } 140 | _ => { 141 | info!(path = %config_path, "Wrote goldboot config successfully"); 142 | ExitCode::SUCCESS 143 | } 144 | } 145 | } 146 | _ => panic!(), 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /goldboot/src/cli/cmd/liveusb.rs: -------------------------------------------------------------------------------- 1 | use console::Style; 2 | use dialoguer::{Confirm, theme::ColorfulTheme}; 3 | use std::{path::Path, process::ExitCode}; 4 | use tracing::error; 5 | 6 | use crate::{cli::progress::ProgressBar, builder::os::Os, library::ImageLibrary}; 7 | 8 | pub fn run(cmd: super::Commands) -> ExitCode { 9 | match cmd { 10 | super::Commands::Liveusb { 11 | dest, 12 | include, 13 | confirm, 14 | } => { 15 | let theme = ColorfulTheme { 16 | values_style: Style::new().yellow().dim(), 17 | ..ColorfulTheme::default() 18 | }; 19 | 20 | if !Path::new(&dest).exists() { 21 | return ExitCode::FAILURE; 22 | } 23 | 24 | // Load from library or download 25 | let mut image_handles = match ImageLibrary::find_by_os("Goldboot") { 26 | Ok(image_handles) => image_handles, 27 | Err(_) => return ExitCode::FAILURE, 28 | }; 29 | 30 | // TODO prompt password 31 | if image_handles[0].load(None).is_err() { 32 | return ExitCode::FAILURE; 33 | } 34 | 35 | if !confirm { 36 | if !Confirm::with_theme(&theme) 37 | .with_prompt(format!("Do you want to overwrite: {}?", dest)) 38 | .interact() 39 | .unwrap() 40 | { 41 | return ExitCode::FAILURE; 42 | } 43 | } 44 | 45 | match image_handles[0].write(dest, ProgressBar::Write.new_empty()) { 46 | Err(err) => { 47 | error!(error = ?err, "Failed to write image"); 48 | ExitCode::FAILURE 49 | } 50 | _ => ExitCode::SUCCESS, 51 | } 52 | } 53 | _ => panic!(), 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /goldboot/src/cli/cmd/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::builder::os::Os; 4 | use crate::config::ConfigPath; 5 | 6 | pub mod build; 7 | pub mod deploy; 8 | pub mod image; 9 | pub mod init; 10 | pub mod liveusb; 11 | pub mod registry; 12 | 13 | #[derive(clap::Subcommand, Debug, Clone)] 14 | pub enum Commands { 15 | /// Build a new image 16 | Build { 17 | /// Save a screenshot to ./screenshots after each boot command for 18 | /// debugging 19 | #[clap(long, num_args = 0)] 20 | record: bool, 21 | 22 | /// Insert a breakpoint after each boot command 23 | #[clap(long, num_args = 0)] 24 | debug: bool, 25 | 26 | /// Read the encryption password from STDIN 27 | #[clap(long, num_args = 0)] 28 | read_password: bool, 29 | 30 | /// Disable virtual machine acceleration even when available 31 | #[clap(long, num_args = 0)] 32 | no_accel: bool, 33 | 34 | /// The optional output destination (defaults to image library) 35 | #[clap(long)] 36 | output: Option, 37 | 38 | #[clap(long)] 39 | ovmf_path: Option, 40 | 41 | /// The context directory (containing a goldboot config file) 42 | #[clap(index = 1)] 43 | path: String, 44 | // The image will be run as a virtual machine for testing 45 | // #[clap(long, num_args = 0)] 46 | // virtual: bool 47 | }, 48 | 49 | /// Manage local images 50 | Image { 51 | #[clap(subcommand)] 52 | command: ImageCommands, 53 | }, 54 | 55 | /// Write images to storage 56 | Deploy { 57 | /// The ID or path of the image to write 58 | #[clap(index = 1)] 59 | image: String, 60 | 61 | /// The output destination 62 | #[clap(long)] 63 | output: String, 64 | 65 | /// Do not prompt for confirmation (be extremely careful with this) 66 | #[clap(long, num_args = 0)] 67 | confirm: bool, 68 | }, 69 | 70 | /// Initialize the current directory as a new goldboot project 71 | Init { 72 | /// New image name 73 | #[clap(long)] 74 | name: Option, 75 | 76 | /// Base operating system(s) 77 | #[clap(long, value_enum)] 78 | os: Vec, 79 | 80 | #[clap(long, default_value_t, value_enum)] 81 | format: ConfigPath, 82 | 83 | // #[clap(long, num_args = 0)] 84 | // list: bool, 85 | /// Attempt to copy the configuration of the current hardware as closely 86 | /// as possible 87 | #[clap(long, num_args = 0)] 88 | mimic_hardware: bool, 89 | }, 90 | 91 | /// Manage image registries 92 | Registry { 93 | #[clap(subcommand)] 94 | command: RegistryCommands, 95 | }, 96 | 97 | /// Create a bootable live USB 98 | Liveusb { 99 | /// Destination device path 100 | #[clap(long)] 101 | dest: String, 102 | 103 | /// Images to include in the live USB 104 | #[clap(long, value_enum)] 105 | include: Vec, 106 | 107 | /// Do not prompt for confirmation (be extremely careful with this) 108 | #[clap(long, num_args = 0)] 109 | confirm: bool, 110 | }, 111 | } 112 | 113 | #[derive(clap::Subcommand, Debug, Clone)] 114 | pub enum RegistryCommands { 115 | /// Enter a token for a registry 116 | Login {}, 117 | 118 | /// Upload a local image to a remote registry 119 | Push { url: String }, 120 | 121 | /// Download an image from a remote registry 122 | Pull { url: String }, 123 | } 124 | 125 | #[derive(clap::Subcommand, Debug, Clone)] 126 | pub enum ImageCommands { 127 | /// List local images 128 | List {}, 129 | 130 | /// Get detailed image info 131 | Info { image: Option }, 132 | } 133 | -------------------------------------------------------------------------------- /goldboot/src/cli/cmd/registry.rs: -------------------------------------------------------------------------------- 1 | use console::Style; 2 | use dialoguer::{Input, theme::ColorfulTheme}; 3 | use std::process::ExitCode; 4 | 5 | use super::RegistryCommands; 6 | 7 | pub fn run(cmd: super::Commands) -> ExitCode { 8 | let theme = ColorfulTheme { 9 | values_style: Style::new().yellow().dim(), 10 | ..ColorfulTheme::default() 11 | }; 12 | 13 | match cmd { 14 | super::Commands::Registry { command } => match &command { 15 | RegistryCommands::Push { url: _ } => todo!(), 16 | RegistryCommands::Pull { url: _ } => todo!(), 17 | RegistryCommands::Login {} => { 18 | // Prompt registry URL 19 | let _registry_url: String = Input::with_theme(&theme) 20 | .with_prompt("Enter registry URL") 21 | .interact() 22 | .unwrap(); 23 | 24 | // Prompt registry token 25 | let _registry_token: String = Input::with_theme(&theme) 26 | .with_prompt("Enter registry token") 27 | .interact() 28 | .unwrap(); 29 | 30 | // Prompt token passphrase 31 | let _token_passphrase: String = Input::with_theme(&theme) 32 | .with_prompt( 33 | "Enter a passphrase to encrypt the token or nothing to store plaintext", 34 | ) 35 | .interact() 36 | .unwrap(); 37 | ExitCode::SUCCESS 38 | } 39 | }, 40 | _ => panic!(), 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /goldboot/src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cmd; 2 | pub mod progress; 3 | pub mod prompt; 4 | -------------------------------------------------------------------------------- /goldboot/src/cli/progress.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::io::IsTerminal; 3 | use std::{ 4 | cmp::min, 5 | io::{Read, Write}, 6 | time::Duration, 7 | }; 8 | 9 | pub enum ProgressBar { 10 | /// A hashing operation 11 | Hash, 12 | 13 | /// An image conversion operation 14 | Convert, 15 | 16 | /// A download operation 17 | Download, 18 | 19 | /// An image write operation 20 | Write, 21 | } 22 | 23 | impl ProgressBar { 24 | fn create_progressbar(&self, len: u64) -> indicatif::ProgressBar { 25 | match self { 26 | ProgressBar::Hash => { 27 | let progress = indicatif::ProgressBar::new(len); 28 | progress.set_style(indicatif::ProgressStyle::default_bar().template("{spinner:.blue} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})").unwrap().progress_chars("=>-")); 29 | progress.enable_steady_tick(Duration::from_millis(50)); 30 | progress 31 | } 32 | ProgressBar::Convert => { 33 | let progress = indicatif::ProgressBar::new(len); 34 | progress.set_style(indicatif::ProgressStyle::default_bar().template("{spinner:.yellow} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})").unwrap().progress_chars("=>-")); 35 | progress.enable_steady_tick(Duration::from_millis(50)); 36 | progress 37 | } 38 | ProgressBar::Download => { 39 | let progress = indicatif::ProgressBar::new(len); 40 | progress.set_style(indicatif::ProgressStyle::default_bar().template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})").unwrap().progress_chars("=>-")); 41 | progress.enable_steady_tick(Duration::from_millis(50)); 42 | progress 43 | } 44 | ProgressBar::Write => { 45 | let progress = indicatif::ProgressBar::new(len); 46 | progress.set_style(indicatif::ProgressStyle::default_bar().template("{spinner:.red} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})").unwrap().progress_chars("=>-")); 47 | progress.enable_steady_tick(Duration::from_millis(50)); 48 | progress 49 | } 50 | } 51 | } 52 | 53 | pub fn new(&self, len: u64) -> Box { 54 | if !show_progress() { 55 | // No progress bar 56 | return Box::new(|_| {}); 57 | } 58 | 59 | let progress = self.create_progressbar(len); 60 | Box::new(move |v| { 61 | if progress.position() + v >= len { 62 | progress.finish_and_clear(); 63 | } else { 64 | progress.inc(v); 65 | } 66 | }) 67 | } 68 | 69 | pub fn new_empty(&self) -> Box { 70 | if !show_progress() { 71 | // No progress bar 72 | return Box::new(|_, _| {}); 73 | } 74 | 75 | let progress = self.create_progressbar(0); 76 | Box::new(move |v, t| { 77 | progress.set_length(t); 78 | if progress.position() + v >= t { 79 | progress.finish_and_clear(); 80 | } else { 81 | progress.inc(v); 82 | } 83 | }) 84 | } 85 | 86 | /// Fully copy the given reader to the given writer and display a 87 | /// progressbar if running in interactive mode. 88 | pub fn copy(&self, reader: &mut dyn Read, writer: &mut dyn Write, len: u64) -> Result<()> { 89 | if !show_progress() { 90 | // No progress bar 91 | std::io::copy(reader, writer)?; 92 | return Ok(()); 93 | } 94 | 95 | let progress = self.create_progressbar(len); 96 | 97 | let mut buffer = [0u8; 1024 * 1024]; 98 | let mut copied: u64 = 0; 99 | 100 | loop { 101 | if let Ok(size) = reader.read(&mut buffer) { 102 | if size == 0 { 103 | break; 104 | } 105 | writer.write(&buffer[0..size])?; 106 | let new = min(copied + (size as u64), len); 107 | copied = new; 108 | progress.set_position(new); 109 | } else { 110 | break; 111 | } 112 | } 113 | 114 | progress.finish_and_clear(); 115 | Ok(()) 116 | } 117 | } 118 | fn show_progress() -> bool { 119 | std::io::stdout().is_terminal() && !std::env::var("CI").is_ok() 120 | } 121 | -------------------------------------------------------------------------------- /goldboot/src/cli/prompt.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use enum_dispatch::enum_dispatch; 3 | 4 | use crate::builder::Builder; 5 | 6 | /// Prompt the user for additional information on the command line. 7 | #[enum_dispatch(Os)] 8 | pub trait Prompt { 9 | fn prompt(&mut self, builder: &Builder) -> Result<()>; 10 | } 11 | -------------------------------------------------------------------------------- /goldboot/src/gbl.rs: -------------------------------------------------------------------------------- 1 | 2 | pub fn merge(qcow_images: Vec) { 3 | 4 | } -------------------------------------------------------------------------------- /goldboot/src/gui/app.rs: -------------------------------------------------------------------------------- 1 | use super::{ 2 | resources::TextureCache, 3 | screens::{registry_login, Screen}, 4 | state::AppState, 5 | theme::Theme, 6 | }; 7 | 8 | pub struct GuiApp { 9 | pub screen: Screen, 10 | pub state: AppState, 11 | pub theme: Theme, 12 | pub textures: TextureCache, 13 | } 14 | 15 | impl GuiApp { 16 | pub fn new(cc: &eframe::CreationContext<'_>) -> Self { 17 | let theme = Theme::default(); 18 | theme.apply_to_context(&cc.egui_ctx); 19 | 20 | Self { 21 | screen: Screen::SelectImage, 22 | state: AppState::new(), 23 | theme, 24 | textures: TextureCache::new(&cc.egui_ctx), 25 | } 26 | } 27 | } 28 | 29 | impl eframe::App for GuiApp { 30 | fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { 31 | // Render grid background 32 | self.theme.render_background(ctx); 33 | 34 | // Handle global hotkeys 35 | self.handle_hotkeys(ctx); 36 | 37 | // Main panel 38 | egui::CentralPanel::default() 39 | .frame(egui::Frame::none()) 40 | .show(ctx, |ui| { 41 | self.screen 42 | .render(ui, &mut self.state, &self.textures, &self.theme); 43 | }); 44 | 45 | // Render registry login dialog if open 46 | registry_login::render(ctx, &mut self.state, &self.theme); 47 | } 48 | } 49 | 50 | impl GuiApp { 51 | fn handle_hotkeys(&mut self, ctx: &egui::Context) { 52 | ctx.input(|i| { 53 | // Esc - Quit application 54 | if i.key_pressed(egui::Key::Escape) && !self.state.show_registry_dialog { 55 | std::process::exit(0); 56 | } 57 | 58 | // F5 - Open registry login dialog 59 | if i.key_pressed(egui::Key::F5) && self.screen == Screen::SelectImage { 60 | self.state.show_registry_dialog = true; 61 | } 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /goldboot/src/gui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod resources; 3 | pub mod screens; 4 | pub mod state; 5 | pub mod theme; 6 | pub mod widgets; 7 | 8 | use std::process::ExitCode; 9 | 10 | pub fn run_gui(fullscreen: bool) -> ExitCode { 11 | let native_options = eframe::NativeOptions { 12 | viewport: egui::ViewportBuilder::default() 13 | .with_inner_size([1920.0, 1080.0]) 14 | .with_fullscreen(fullscreen) 15 | .with_decorations(!fullscreen) 16 | .with_title("goldboot"), 17 | ..Default::default() 18 | }; 19 | 20 | match eframe::run_native( 21 | "goldboot", 22 | native_options, 23 | Box::new(|cc| Ok(Box::new(app::GuiApp::new(cc)))), 24 | ) { 25 | Ok(_) => ExitCode::SUCCESS, 26 | Err(e) => { 27 | eprintln!("GUI error: {}", e); 28 | ExitCode::FAILURE 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /goldboot/src/gui/resources.rs: -------------------------------------------------------------------------------- 1 | use image::ImageReader; 2 | use std::io::Cursor; 3 | 4 | pub const LOGO_BYTES: &[u8] = include_bytes!("resources/logo-512.png"); 5 | pub const ICON_HDD: &[u8] = include_bytes!("resources/icons/hdd.png"); 6 | pub const ICON_SSD: &[u8] = include_bytes!("resources/icons/ssd.png"); 7 | pub const ICON_NVME: &[u8] = include_bytes!("resources/icons/nvme.png"); 8 | pub const ICON_RAM: &[u8] = include_bytes!("resources/icons/ram.png"); 9 | 10 | pub fn load_image_from_bytes(bytes: &[u8]) -> Result { 11 | let image = ImageReader::new(Cursor::new(bytes)) 12 | .with_guessed_format() 13 | .map_err(|e| format!("Failed to guess format: {}", e))? 14 | .decode() 15 | .map_err(|e| format!("Failed to decode: {}", e))?; 16 | 17 | let size = [image.width() as usize, image.height() as usize]; 18 | let rgba = image.to_rgba8(); 19 | let pixels = rgba.as_flat_samples(); 20 | 21 | Ok(egui::ColorImage::from_rgba_unmultiplied( 22 | size, 23 | pixels.as_slice(), 24 | )) 25 | } 26 | 27 | pub struct TextureCache { 28 | pub logo: egui::TextureHandle, 29 | pub icon_hdd: egui::TextureHandle, 30 | pub icon_ssd: egui::TextureHandle, 31 | pub icon_nvme: egui::TextureHandle, 32 | pub icon_ram: egui::TextureHandle, 33 | } 34 | 35 | impl TextureCache { 36 | pub fn new(ctx: &egui::Context) -> Self { 37 | Self { 38 | logo: ctx.load_texture( 39 | "logo", 40 | load_image_from_bytes(LOGO_BYTES).expect("Failed to load logo"), 41 | Default::default(), 42 | ), 43 | icon_hdd: ctx.load_texture( 44 | "icon_hdd", 45 | load_image_from_bytes(ICON_HDD).expect("Failed to load HDD icon"), 46 | Default::default(), 47 | ), 48 | icon_ssd: ctx.load_texture( 49 | "icon_ssd", 50 | load_image_from_bytes(ICON_SSD).expect("Failed to load SSD icon"), 51 | Default::default(), 52 | ), 53 | icon_nvme: ctx.load_texture( 54 | "icon_nvme", 55 | load_image_from_bytes(ICON_NVME).expect("Failed to load NVME icon"), 56 | Default::default(), 57 | ), 58 | icon_ram: ctx.load_texture( 59 | "icon_ram", 60 | load_image_from_bytes(ICON_RAM).expect("Failed to load RAM icon"), 61 | Default::default(), 62 | ), 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /goldboot/src/gui/resources/icons/hdd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/gui/resources/icons/hdd.png -------------------------------------------------------------------------------- /goldboot/src/gui/resources/icons/nvme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/gui/resources/icons/nvme.png -------------------------------------------------------------------------------- /goldboot/src/gui/resources/icons/ram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/gui/resources/icons/ram.png -------------------------------------------------------------------------------- /goldboot/src/gui/resources/icons/ssd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/gui/resources/icons/ssd.png -------------------------------------------------------------------------------- /goldboot/src/gui/resources/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossable/goldboot/HEAD/goldboot/src/gui/resources/logo-512.png -------------------------------------------------------------------------------- /goldboot/src/gui/screens/confirm.rs: -------------------------------------------------------------------------------- 1 | use super::super::{resources::TextureCache, state::AppState, theme::Theme, widgets}; 2 | use super::Screen; 3 | 4 | pub fn render( 5 | ui: &mut egui::Ui, 6 | state: &mut AppState, 7 | textures: &TextureCache, 8 | theme: &Theme, 9 | screen: &mut Screen, 10 | ) { 11 | ui.vertical(|ui| { 12 | // Header with logo 13 | widgets::header::render(ui, textures, theme); 14 | 15 | // Warning 16 | ui.vertical_centered(|ui| { 17 | ui.label( 18 | egui::RichText::new("Are you sure?") 19 | .color(theme.text_secondary) 20 | .strong() 21 | .size(16.0), 22 | ); 23 | }); 24 | 25 | ui.add_space(20.0); 26 | 27 | // Progress bar (400px wide as per GTK, centered) 28 | ui.vertical_centered(|ui| { 29 | let progress_text = format!("{}%", (state.confirm_progress * 100.0) as i32); 30 | 31 | let progress_bar = egui::ProgressBar::new(state.confirm_progress) 32 | .show_percentage() 33 | .text(progress_text); 34 | 35 | ui.add_sized([400.0, 20.0], progress_bar); 36 | 37 | ui.add_space(10.0); 38 | 39 | ui.label( 40 | egui::RichText::new("Press Enter 100 times to confirm") 41 | .color(theme.text_secondary) 42 | .size(12.0), 43 | ); 44 | }); 45 | 46 | // Check for Enter key press 47 | if ui.input(|i| i.key_pressed(egui::Key::Enter)) { 48 | state.confirm_progress += 0.01; 49 | if state.confirm_progress >= 1.0 { 50 | state.confirm_progress = 1.0; 51 | 52 | // Initialize write progress for demo (10GB image) 53 | // TODO: Use actual image size from selected_image 54 | state.init_write_progress(10 * 1024 * 1024 * 1024); 55 | 56 | // Navigate to ApplyImage screen 57 | *screen = Screen::ApplyImage; 58 | } 59 | } 60 | 61 | ui.add_space(20.0); 62 | 63 | // Hotkeys footer 64 | let hotkeys = vec![("Esc", "Quit"), ("Enter", "Confirm (hold)")]; 65 | widgets::hotkeys::render(ui, &hotkeys, theme); 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /goldboot/src/gui/screens/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod apply_image; 2 | pub mod confirm; 3 | pub mod registry_login; 4 | pub mod select_device; 5 | pub mod select_image; 6 | 7 | use super::{resources::TextureCache, state::AppState, theme::Theme}; 8 | 9 | #[derive(Debug, Clone, PartialEq)] 10 | pub enum Screen { 11 | SelectImage, 12 | SelectDevice, 13 | Confirm, 14 | ApplyImage, 15 | } 16 | 17 | impl Screen { 18 | pub fn render( 19 | &mut self, 20 | ui: &mut egui::Ui, 21 | state: &mut AppState, 22 | textures: &TextureCache, 23 | theme: &Theme, 24 | ) { 25 | match self { 26 | Screen::SelectImage => select_image::render(ui, state, textures, theme, self), 27 | Screen::SelectDevice => select_device::render(ui, state, textures, theme, self), 28 | Screen::Confirm => confirm::render(ui, state, textures, theme, self), 29 | Screen::ApplyImage => apply_image::render(ui, state, textures, theme, self), 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /goldboot/src/gui/screens/registry_login.rs: -------------------------------------------------------------------------------- 1 | use super::super::{state::AppState, theme::Theme}; 2 | 3 | pub fn render(ctx: &egui::Context, state: &mut AppState, theme: &Theme) { 4 | if !state.show_registry_dialog { 5 | return; 6 | } 7 | 8 | egui::Window::new("Registry Login") 9 | .collapsible(false) 10 | .resizable(false) 11 | .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) 12 | .show(ctx, |ui| { 13 | ui.label("Registry Address:"); 14 | ui.text_edit_singleline(&mut state.registry_address); 15 | 16 | ui.add_space(10.0); 17 | 18 | ui.label("Password:"); 19 | let password_edit = egui::TextEdit::singleline(&mut state.registry_password) 20 | .password(true); 21 | ui.add(password_edit); 22 | 23 | ui.add_space(20.0); 24 | 25 | ui.horizontal(|ui| { 26 | if ui.button("Login").clicked() { 27 | // TODO: Implement registry login 28 | state.show_registry_dialog = false; 29 | } 30 | 31 | if ui.button("Cancel").clicked() { 32 | state.show_registry_dialog = false; 33 | } 34 | }); 35 | }); 36 | 37 | // Check for Escape key to close dialog 38 | if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { 39 | state.show_registry_dialog = false; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /goldboot/src/gui/screens/select_device.rs: -------------------------------------------------------------------------------- 1 | use super::super::{resources::TextureCache, state::AppState, theme::Theme, widgets}; 2 | use super::Screen; 3 | use ubyte::ToByteUnit; 4 | 5 | pub fn render( 6 | ui: &mut egui::Ui, 7 | state: &mut AppState, 8 | textures: &TextureCache, 9 | theme: &Theme, 10 | screen: &mut Screen, 11 | ) { 12 | // Load devices if not already loaded 13 | if state.devices.is_empty() { 14 | if let Ok(block_devices) = block_utils::get_block_devices() { 15 | if let Ok(devices) = block_utils::get_all_device_info(block_devices) { 16 | state.devices = devices; 17 | } 18 | } 19 | } 20 | 21 | ui.vertical(|ui| { 22 | // Header with logo 23 | widgets::header::render(ui, textures, theme); 24 | 25 | // Warning prompt 26 | ui.vertical_centered(|ui| { 27 | ui.label( 28 | egui::RichText::new("Select a device below to OVERWRITE") 29 | .color(theme.text_secondary) 30 | .strong() 31 | .size(16.0), 32 | ); 33 | }); 34 | 35 | ui.add_space(10.0); 36 | 37 | // Device list with horizontal margins (100px as per GTK) 38 | ui.horizontal(|ui| { 39 | ui.add_space(100.0); 40 | 41 | egui::ScrollArea::vertical() 42 | .auto_shrink([false, false]) 43 | .show(ui, |ui| { 44 | ui.push_id("device_list", |ui| { 45 | let available_width = ui.available_width() - 200.0; 46 | 47 | egui::Frame::none() 48 | .stroke(egui::Stroke::new(3.0, theme.border.linear_multiply(0.75))) 49 | .fill(theme.list_bg) 50 | .inner_margin(15.0) 51 | .show(ui, |ui| { 52 | ui.set_width(available_width); 53 | 54 | if state.devices.is_empty() { 55 | ui.label( 56 | egui::RichText::new("No devices found") 57 | .color(theme.text_secondary), 58 | ); 59 | } else { 60 | for device in state.devices.iter() { 61 | let is_selected = 62 | state.selected_device.as_ref() == Some(&device.name); 63 | 64 | let response = ui.horizontal(|ui| { 65 | ui.add_space(5.0); 66 | 67 | // Device icon (32x32) 68 | let icon = match device.media_type { 69 | block_utils::MediaType::SolidState => &textures.icon_ssd, 70 | block_utils::MediaType::Rotational => &textures.icon_hdd, 71 | block_utils::MediaType::NVME => &textures.icon_nvme, 72 | block_utils::MediaType::Ram => &textures.icon_ram, 73 | _ => &textures.icon_hdd, // Fallback 74 | }; 75 | 76 | ui.add(egui::Image::new(icon).max_width(32.0)); 77 | ui.add_space(5.0); 78 | 79 | // Device name and serial 80 | let device_label = if let Some(serial) = &device.serial_number { 81 | format!("{} ({})", device.name, serial) 82 | } else { 83 | device.name.clone() 84 | }; 85 | 86 | ui.label( 87 | egui::RichText::new(device_label) 88 | .color(theme.text_primary), 89 | ); 90 | 91 | ui.with_layout( 92 | egui::Layout::right_to_left(egui::Align::Center), 93 | |ui| { 94 | ui.add_space(5.0); 95 | 96 | // Device capacity 97 | ui.label( 98 | egui::RichText::new(device.capacity.bytes().to_string()) 99 | .color(theme.text_primary), 100 | ); 101 | }, 102 | ); 103 | }); 104 | 105 | let response = response.response.interact(egui::Sense::click()); 106 | 107 | if response.clicked() { 108 | state.selected_device = Some(device.name.clone()); 109 | // Navigate to Confirm screen 110 | *screen = Screen::Confirm; 111 | } 112 | 113 | if response.hovered() { 114 | ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); 115 | } 116 | 117 | // Check for Enter key to select 118 | if is_selected && ui.input(|i| i.key_pressed(egui::Key::Enter)) { 119 | *screen = Screen::Confirm; 120 | } 121 | 122 | ui.add_space(5.0); 123 | } 124 | } 125 | }); 126 | }); 127 | }); 128 | 129 | ui.add_space(100.0); 130 | }); 131 | 132 | ui.add_space(20.0); 133 | 134 | // Hotkeys footer 135 | let hotkeys = vec![("Esc", "Quit"), ("Enter", "Overwrite")]; 136 | widgets::hotkeys::render(ui, &hotkeys, theme); 137 | }); 138 | } 139 | -------------------------------------------------------------------------------- /goldboot/src/gui/screens/select_image.rs: -------------------------------------------------------------------------------- 1 | use super::super::{resources::TextureCache, state::AppState, theme::Theme, widgets}; 2 | use super::Screen; 3 | use ubyte::ToByteUnit; 4 | 5 | pub fn render( 6 | ui: &mut egui::Ui, 7 | state: &mut AppState, 8 | textures: &TextureCache, 9 | theme: &Theme, 10 | screen: &mut Screen, 11 | ) { 12 | ui.vertical(|ui| { 13 | // Header with logo 14 | widgets::header::render(ui, textures, theme); 15 | 16 | // Prompt 17 | ui.vertical_centered(|ui| { 18 | ui.label( 19 | egui::RichText::new("Select an available image below") 20 | .color(theme.text_secondary) 21 | .strong() 22 | .size(16.0), 23 | ); 24 | }); 25 | 26 | ui.add_space(10.0); 27 | 28 | // Image list with horizontal margins (100px as per GTK) 29 | ui.horizontal(|ui| { 30 | ui.add_space(100.0); 31 | 32 | egui::ScrollArea::vertical() 33 | .auto_shrink([false, false]) 34 | .show(ui, |ui| { 35 | ui.push_id("image_list", |ui| { 36 | let available_width = ui.available_width() - 200.0; // Account for margins 37 | 38 | egui::Frame::none() 39 | .stroke(egui::Stroke::new(3.0, theme.border.linear_multiply(0.75))) 40 | .fill(theme.list_bg) 41 | .inner_margin(15.0) 42 | .show(ui, |ui| { 43 | ui.set_width(available_width); 44 | 45 | if state.images.is_empty() { 46 | ui.label( 47 | egui::RichText::new("No images found") 48 | .color(theme.text_secondary), 49 | ); 50 | } else { 51 | for image in state.images.iter() { 52 | let is_selected = 53 | state.selected_image.as_ref() == Some(&image.id); 54 | 55 | let response = ui.horizontal(|ui| { 56 | ui.add_space(5.0); 57 | 58 | // Image name 59 | ui.label( 60 | egui::RichText::new(image.primary_header.name()) 61 | .color(theme.text_primary), 62 | ); 63 | 64 | ui.add_space(20.0); 65 | 66 | // Image path 67 | ui.label( 68 | egui::RichText::new(image.path.to_string_lossy()) 69 | .color(theme.text_primary), 70 | ); 71 | 72 | ui.with_layout( 73 | egui::Layout::right_to_left(egui::Align::Center), 74 | |ui| { 75 | ui.add_space(5.0); 76 | 77 | // Image size 78 | ui.label( 79 | egui::RichText::new( 80 | image.primary_header.size.bytes().to_string(), 81 | ) 82 | .color(theme.text_primary), 83 | ); 84 | }, 85 | ); 86 | }); 87 | 88 | let response = response.response.interact(egui::Sense::click()); 89 | 90 | if response.clicked() { 91 | state.selected_image = Some(image.id.clone()); 92 | // Navigate to SelectDevice screen 93 | *screen = Screen::SelectDevice; 94 | } 95 | 96 | if response.hovered() { 97 | ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); 98 | } 99 | 100 | // Check for Enter key to select 101 | if is_selected && ui.input(|i| i.key_pressed(egui::Key::Enter)) { 102 | *screen = Screen::SelectDevice; 103 | } 104 | 105 | ui.add_space(5.0); 106 | } 107 | } 108 | }); 109 | }); 110 | }); 111 | 112 | ui.add_space(100.0); 113 | }); 114 | 115 | ui.add_space(20.0); 116 | 117 | // Hotkeys footer 118 | let hotkeys = vec![ 119 | ("Esc", "Quit"), 120 | ("F5", "Registry Login"), 121 | ("Enter", "Select Image"), 122 | ]; 123 | widgets::hotkeys::render(ui, &hotkeys, theme); 124 | }); 125 | } 126 | -------------------------------------------------------------------------------- /goldboot/src/gui/state.rs: -------------------------------------------------------------------------------- 1 | use goldboot_image::ImageHandle; 2 | use std::sync::{Arc, Mutex}; 3 | use std::time::Instant; 4 | 5 | #[derive(Debug, Clone, Copy, PartialEq)] 6 | pub enum BlockState { 7 | Pending, // Not yet written 8 | Writing, // Currently being written 9 | Written, // Successfully written 10 | } 11 | 12 | pub struct WriteProgress { 13 | pub percentage: f32, // 0.0 to 1.0 14 | pub read_speed: f64, // Bytes per second 15 | pub write_speed: f64, // Bytes per second 16 | pub blocks_total: usize, // Total number of blocks 17 | pub blocks_written: usize, // Number of blocks written 18 | pub blocks_writing: usize, // Number of blocks currently being written 19 | pub block_states: Vec, // State of each block 20 | pub start_time: Instant, 21 | } 22 | 23 | impl WriteProgress { 24 | pub fn new(total_blocks: usize) -> Self { 25 | Self { 26 | percentage: 0.0, 27 | read_speed: 0.0, 28 | write_speed: 0.0, 29 | blocks_total: total_blocks, 30 | blocks_written: 0, 31 | blocks_writing: 0, 32 | block_states: vec![BlockState::Pending; total_blocks], 33 | start_time: Instant::now(), 34 | } 35 | } 36 | 37 | pub fn elapsed_seconds(&self) -> f64 { 38 | self.start_time.elapsed().as_secs_f64() 39 | } 40 | } 41 | 42 | pub struct AppState { 43 | // Image selection 44 | pub images: Vec, 45 | pub selected_image: Option, 46 | 47 | // Device selection 48 | pub devices: Vec, 49 | pub selected_device: Option, 50 | 51 | // Confirmation 52 | pub confirm_progress: f32, // 0.0 to 1.0 53 | 54 | // Registry login 55 | pub registry_address: String, 56 | pub registry_password: String, 57 | pub show_registry_dialog: bool, 58 | 59 | // Image writing - detailed progress tracking 60 | pub write_progress: Option>>, 61 | } 62 | 63 | impl AppState { 64 | pub fn new() -> Self { 65 | Self { 66 | images: crate::library::ImageLibrary::find_all().unwrap_or_default(), 67 | selected_image: None, 68 | devices: Vec::new(), // Loaded on-demand in select_device screen 69 | selected_device: None, 70 | confirm_progress: 0.0, 71 | registry_address: String::new(), 72 | registry_password: String::new(), 73 | show_registry_dialog: false, 74 | write_progress: None, 75 | } 76 | } 77 | 78 | /// Initialize write progress when entering ApplyImage screen 79 | pub fn init_write_progress(&mut self, total_size_bytes: u64) { 80 | // Calculate number of blocks (using 4MB blocks for visualization) 81 | const BLOCK_SIZE: u64 = 4 * 1024 * 1024; // 4MB blocks 82 | let total_blocks = ((total_size_bytes + BLOCK_SIZE - 1) / BLOCK_SIZE) as usize; 83 | 84 | // Cap at reasonable number for visualization (e.g., 1000 blocks max) 85 | let total_blocks = total_blocks.min(1000); 86 | 87 | self.write_progress = Some(Arc::new(Mutex::new(WriteProgress::new(total_blocks)))); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /goldboot/src/gui/theme.rs: -------------------------------------------------------------------------------- 1 | use egui::{Color32, Style, Visuals}; 2 | 3 | pub struct Theme { 4 | pub bg_primary: Color32, // #333333 5 | pub bg_grid: Color32, // #4a4a4a 6 | pub accent_gold: Color32, // #c8ab37 7 | pub text_primary: Color32, // #ffffff (white) 8 | pub text_secondary: Color32, // #aea79f (beige) 9 | pub list_bg: Color32, // #333333 with 0.75 opacity 10 | pub border: Color32, // #c8ab37 11 | } 12 | 13 | impl Default for Theme { 14 | fn default() -> Self { 15 | Self { 16 | bg_primary: Color32::from_rgb(0x33, 0x33, 0x33), 17 | bg_grid: Color32::from_rgb(0x4a, 0x4a, 0x4a), 18 | accent_gold: Color32::from_rgb(0xc8, 0xab, 0x37), 19 | text_primary: Color32::WHITE, 20 | text_secondary: Color32::from_rgb(0xae, 0xa7, 0x9f), 21 | list_bg: Color32::from_rgba_unmultiplied(0x33, 0x33, 0x33, 191), 22 | border: Color32::from_rgb(0xc8, 0xab, 0x37), 23 | } 24 | } 25 | } 26 | 27 | impl Theme { 28 | pub fn apply_to_context(&self, ctx: &egui::Context) { 29 | let mut style = Style::default(); 30 | style.visuals = Visuals::dark(); 31 | style.visuals.override_text_color = Some(self.text_primary); 32 | style.visuals.selection.bg_fill = self.accent_gold; 33 | style.visuals.selection.stroke.color = self.accent_gold; 34 | style.visuals.widgets.noninteractive.bg_stroke.color = self.border; 35 | ctx.set_style(style); 36 | } 37 | 38 | pub fn render_background(&self, ctx: &egui::Context) { 39 | let painter = ctx.layer_painter(egui::LayerId::background()); 40 | let rect = ctx.screen_rect(); 41 | 42 | // Fill with primary background color 43 | painter.rect_filled(rect, 0.0, self.bg_primary); 44 | 45 | // Draw 80x80px grid 46 | let grid_size = 80.0; 47 | let stroke = egui::Stroke::new(1.0, self.bg_grid); 48 | 49 | // Vertical lines 50 | let mut x = (rect.min.x / grid_size).floor() * grid_size; 51 | while x <= rect.max.x { 52 | painter.line_segment( 53 | [egui::pos2(x, rect.min.y), egui::pos2(x, rect.max.y)], 54 | stroke, 55 | ); 56 | x += grid_size; 57 | } 58 | 59 | // Horizontal lines 60 | let mut y = (rect.min.y / grid_size).floor() * grid_size; 61 | while y <= rect.max.y { 62 | painter.line_segment( 63 | [egui::pos2(rect.min.x, y), egui::pos2(rect.max.x, y)], 64 | stroke, 65 | ); 66 | y += grid_size; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /goldboot/src/gui/widgets/header.rs: -------------------------------------------------------------------------------- 1 | use super::super::{resources::TextureCache, theme::Theme}; 2 | 3 | pub fn render(ui: &mut egui::Ui, textures: &TextureCache, theme: &Theme) { 4 | ui.vertical_centered(|ui| { 5 | ui.add_space(20.0); 6 | 7 | // Display logo (512px wide as per GTK version) 8 | let logo_size = egui::vec2(512.0, textures.logo.size()[1] as f32 * (512.0 / textures.logo.size()[0] as f32)); 9 | ui.add(egui::Image::new(&textures.logo).max_width(logo_size.x)); 10 | 11 | ui.add_space(10.0); 12 | 13 | // Version info (debug builds only) 14 | #[cfg(debug_assertions)] 15 | { 16 | let version = env!("CARGO_PKG_VERSION"); 17 | let git_hash = option_env!("GIT_HASH").unwrap_or("unknown"); 18 | let build_date = option_env!("BUILD_DATE").unwrap_or("unknown"); 19 | 20 | let version_text = format!("goldboot v{}-{} ({})", version, git_hash, build_date); 21 | 22 | ui.label( 23 | egui::RichText::new(version_text) 24 | .color(theme.text_secondary) 25 | .monospace(), 26 | ); 27 | 28 | ui.add_space(10.0); 29 | } 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /goldboot/src/gui/widgets/hotkeys.rs: -------------------------------------------------------------------------------- 1 | use super::super::theme::Theme; 2 | 3 | pub fn render(ui: &mut egui::Ui, hotkeys: &[(&str, &str)], theme: &Theme) { 4 | ui.add_space(20.0); 5 | 6 | ui.horizontal(|ui| { 7 | ui.add_space(10.0); 8 | 9 | for (key, description) in hotkeys { 10 | ui.label( 11 | egui::RichText::new(format!("[{}] {}", key, description)) 12 | .color(theme.text_secondary) 13 | .monospace() 14 | .strong(), 15 | ); 16 | 17 | ui.add_space(20.0); 18 | } 19 | }); 20 | 21 | ui.add_space(10.0); 22 | } 23 | -------------------------------------------------------------------------------- /goldboot/src/gui/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod header; 2 | pub mod hotkeys; 3 | -------------------------------------------------------------------------------- /goldboot/src/lib.rs: -------------------------------------------------------------------------------- 1 | use rand::Rng; 2 | 3 | use std::net::TcpListener; 4 | 5 | pub mod cli; 6 | pub mod config; 7 | pub mod builder; 8 | #[cfg(feature = "gui")] 9 | pub mod gui; 10 | pub mod library; 11 | pub mod registry; 12 | 13 | /// Build info 14 | pub mod built_info { 15 | include!(concat!(env!("OUT_DIR"), "/built.rs")); 16 | } 17 | 18 | /// Find a random open TCP port in the given range. 19 | pub fn find_open_port(lower: u16, upper: u16) -> u16 { 20 | loop { 21 | let port = rand::rng().random_range(lower..upper); 22 | match TcpListener::bind(format!("0.0.0.0:{port}")) { 23 | Ok(_) => break port, 24 | Err(_) => continue, 25 | } 26 | } 27 | } 28 | 29 | /// Generate a random password 30 | pub fn random_password() -> String { 31 | // TODO check for a dictionary to generate something memorable 32 | 33 | // Fallback to random letters and numbers 34 | rand::rng() 35 | .sample_iter(&rand::distr::Alphanumeric) 36 | .take(12) 37 | .map(char::from) 38 | .collect() 39 | } 40 | 41 | #[cfg(test)] 42 | mod tests { 43 | use super::*; 44 | 45 | #[test] 46 | fn test_find_open_port() { 47 | let port = find_open_port(9000, 9999); 48 | 49 | assert!(port < 9999); 50 | assert!(port >= 9000); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /goldboot/src/library.rs: -------------------------------------------------------------------------------- 1 | use crate::{cli::progress::ProgressBar, builder::os::Os}; 2 | use anyhow::{Result, anyhow, bail}; 3 | use goldboot_image::ImageHandle; 4 | use rand::Rng; 5 | use sha1::Digest; 6 | use sha2::Sha256; 7 | use std::{ 8 | fs::File, 9 | path::{Path, PathBuf}, 10 | }; 11 | use tracing::info; 12 | 13 | /// Represents the local image library. 14 | /// 15 | /// Depending on the platform, the directory will be located at: 16 | /// - /var/lib/goldboot/images (linux) 17 | /// 18 | /// Images are named according to their SHA256 hash (ID) and have a file 19 | /// extension of ".gb". 20 | pub struct ImageLibrary { 21 | pub directory: PathBuf, 22 | } 23 | 24 | impl ImageLibrary { 25 | pub fn open() -> Self { 26 | let directory = if cfg!(target_os = "linux") { 27 | PathBuf::from("/var/lib/goldboot/images") 28 | } else if cfg!(target_os = "macos") { 29 | PathBuf::from("/var/lib/goldboot/images") 30 | } else { 31 | panic!("Unsupported platform"); 32 | }; 33 | 34 | std::fs::create_dir_all(&directory).expect("failed to create image library"); 35 | ImageLibrary { directory } 36 | } 37 | 38 | pub fn temporary(&self) -> PathBuf { 39 | let name: String = rand::rng() 40 | .sample_iter(&rand::distr::Alphanumeric) 41 | .take(12) 42 | .map(char::from) 43 | .collect(); 44 | self.directory.join(name) 45 | } 46 | 47 | /// Add an image to the library. The image will be hashed and copied to the 48 | /// library with the appropriate name. 49 | pub fn add_copy(&self, image_path: impl AsRef) -> Result<()> { 50 | info!("Saving image to library"); 51 | 52 | let mut hasher = Sha256::new(); 53 | ProgressBar::Hash.copy( 54 | &mut File::open(&image_path)?, 55 | &mut hasher, 56 | std::fs::metadata(&image_path)?.len(), 57 | )?; 58 | let hash = hex::encode(hasher.finalize()); 59 | 60 | std::fs::copy(&image_path, self.directory.join(format!("{hash}.gb")))?; 61 | Ok(()) 62 | } 63 | 64 | /// Add an image to the library. The image will be hashed and moved to the 65 | /// library with the appropriate name. 66 | pub fn add_move(&self, image_path: impl AsRef) -> Result<()> { 67 | info!("Saving image to library"); 68 | 69 | let mut hasher = Sha256::new(); 70 | ProgressBar::Hash.copy( 71 | &mut File::open(&image_path)?, 72 | &mut hasher, 73 | std::fs::metadata(&image_path)?.len(), 74 | )?; 75 | let hash = hex::encode(hasher.finalize()); 76 | 77 | std::fs::rename(&image_path, self.directory.join(format!("{hash}.gb")))?; 78 | Ok(()) 79 | } 80 | 81 | /// Remove an image from the library by ID. 82 | pub fn delete(&self, image_id: &str) -> Result<()> { 83 | for p in self.directory.read_dir()? { 84 | let path = p?.path(); 85 | let filename = path.file_name().unwrap().to_str().unwrap(); 86 | 87 | if filename == format!("{image_id}.gb") 88 | || filename == format!("{}.gb", &image_id[0..12]) 89 | { 90 | std::fs::remove_file(path)?; 91 | return Ok(()); 92 | } 93 | } 94 | 95 | Ok(()) 96 | } 97 | 98 | /// Download a goldboot image over HTTP. 99 | pub fn download(&self, url: String) -> Result { 100 | let path = self.directory.join("goldboot-uki.gb"); 101 | 102 | let mut rs = reqwest::blocking::get(&url)?; 103 | if rs.status().is_success() { 104 | let length = rs 105 | .content_length() 106 | .ok_or_else(|| anyhow!("Failed to get content length"))?; 107 | 108 | let mut file = File::create(&path)?; 109 | 110 | info!("Saving goldboot image"); 111 | ProgressBar::Download.copy(&mut rs, &mut file, length)?; 112 | ImageHandle::open(&path) 113 | } else { 114 | bail!("Failed to download"); 115 | } 116 | } 117 | 118 | /// Find images in the library by ID. 119 | pub fn find_by_id(image_id: &str) -> Result { 120 | Ok(Self::find_all()? 121 | .into_iter() 122 | .find(|image| image.id == image_id || image.id[0..12] == image_id[0..12]) 123 | .ok_or_else(|| anyhow!("Image not found"))?) 124 | } 125 | 126 | /// Find images in the library that have the given OS. 127 | pub fn find_by_os(os: &str) -> Result> { 128 | Ok(Self::find_all()? 129 | .into_iter() 130 | .filter(|image| { 131 | image 132 | .primary_header 133 | .elements 134 | .iter() 135 | .any(|element| element.os() == os) 136 | }) 137 | .collect()) 138 | } 139 | 140 | /// Find all images present in the local image library. 141 | pub fn find_all() -> Result> { 142 | let mut images = Vec::new(); 143 | 144 | for p in Self::open().directory.read_dir()? { 145 | let path = p?.path(); 146 | 147 | if let Some(ext) = path.extension() { 148 | if ext == "gb" { 149 | images.push(ImageHandle::open(&path)?); 150 | } 151 | } 152 | } 153 | 154 | Ok(images) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /goldboot/src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use goldboot::cli::cmd::Commands; 3 | use std::process::ExitCode; 4 | 5 | #[cfg(not(feature = "uki"))] 6 | use std::env; 7 | 8 | #[derive(Parser, Debug)] 9 | #[clap(author, version, about, long_about = None)] 10 | struct CommandLine { 11 | #[clap(subcommand)] 12 | command: Option, 13 | 14 | /// Run the GUI in fullscreen mode 15 | #[cfg(feature = "gui")] 16 | #[clap(long, num_args = 0)] 17 | fullscreen: bool, 18 | } 19 | 20 | /// Determine whether builds should be headless or not for debugging. 21 | pub fn build_headless_debug() -> bool { 22 | if env::var("CI").is_ok() { 23 | return true; 24 | } 25 | if env::var("GOLDBOOT_DEBUG").is_ok() { 26 | return false; 27 | } 28 | return true; 29 | } 30 | 31 | pub fn main() -> ExitCode { 32 | // UKI mode: Run fullscreen GUI with automatic environment checks and reboot 33 | #[cfg(feature = "uki")] 34 | return uki_main(); 35 | 36 | // Parse command line options before we configure logging so we can set the 37 | // default level 38 | #[cfg(not(feature = "uki"))] 39 | let command_line = CommandLine::parse(); 40 | 41 | // Configure logging 42 | #[cfg(not(feature = "uki"))] 43 | { 44 | let _default_filter = match &command_line.command { 45 | Some(Commands::Build { debug, .. }) => { 46 | if *debug { 47 | "debug" 48 | } else { 49 | "info" 50 | } 51 | } 52 | _ => "info", 53 | }; 54 | 55 | tracing_subscriber::fmt() 56 | .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) 57 | .init(); 58 | } 59 | 60 | // Dispatch command 61 | #[cfg(not(feature = "uki"))] 62 | match &command_line.command { 63 | Some(Commands::Init { .. }) => goldboot::cli::cmd::init::run(command_line.command.unwrap()), 64 | Some(Commands::Build { .. }) => { 65 | goldboot::cli::cmd::build::run(command_line.command.unwrap()) 66 | } 67 | Some(Commands::Image { .. }) => { 68 | goldboot::cli::cmd::image::run(command_line.command.unwrap()) 69 | } 70 | Some(Commands::Registry { .. }) => { 71 | goldboot::cli::cmd::registry::run(command_line.command.unwrap()) 72 | } 73 | Some(Commands::Deploy { .. }) => { 74 | goldboot::cli::cmd::deploy::run(command_line.command.unwrap()) 75 | } 76 | Some(Commands::Liveusb { .. }) => { 77 | goldboot::cli::cmd::liveusb::run(command_line.command.unwrap()) 78 | } 79 | None => { 80 | #[cfg(feature = "gui")] 81 | { 82 | return goldboot::gui::run_gui(command_line.fullscreen); 83 | } 84 | 85 | #[cfg(not(feature = "gui"))] 86 | { 87 | eprintln!("No command specified. Use --help for usage information."); 88 | eprintln!("Note: GUI requires building with --features gui"); 89 | return ExitCode::FAILURE; 90 | } 91 | } 92 | } 93 | } 94 | 95 | #[cfg(feature = "uki")] 96 | fn uki_main() -> ExitCode { 97 | use tracing::info; 98 | 99 | // Initialize logging for UKI mode 100 | tracing_subscriber::fmt() 101 | .with_env_filter( 102 | tracing_subscriber::EnvFilter::from_default_env() 103 | .add_directive(tracing::Level::INFO.into()), 104 | ) 105 | .init(); 106 | 107 | info!("Starting goldboot in UKI mode"); 108 | 109 | // Check environment 110 | if let Err(e) = check_uki_environment() { 111 | eprintln!("Environment check failed: {}", e); 112 | return ExitCode::FAILURE; 113 | } 114 | 115 | // Run GUI in fullscreen mode 116 | let result = goldboot::gui::run_gui(true); 117 | 118 | // After GUI exits, reboot the system 119 | info!("GUI exited, initiating system reboot"); 120 | if let Err(e) = reboot_system() { 121 | eprintln!("Failed to reboot system: {}", e); 122 | return ExitCode::FAILURE; 123 | } 124 | 125 | result 126 | } 127 | 128 | #[cfg(feature = "uki")] 129 | fn check_uki_environment() -> Result<(), String> { 130 | // Verify we have access to block devices 131 | if !std::path::Path::new("/sys/class/block").exists() { 132 | return Err("Block device sysfs not available".to_string()); 133 | } 134 | 135 | // Verify the image library directory exists 136 | let lib_path = std::path::Path::new("/var/lib/goldboot/images"); 137 | if !lib_path.exists() { 138 | std::fs::create_dir_all(lib_path) 139 | .map_err(|e| format!("Failed to create library directory: {}", e))?; 140 | } 141 | 142 | Ok(()) 143 | } 144 | 145 | #[cfg(feature = "uki")] 146 | fn reboot_system() -> Result<(), String> { 147 | use std::process::Command; 148 | 149 | // Try systemctl first (if systemd is available) 150 | let result = Command::new("systemctl") 151 | .arg("reboot") 152 | .status(); 153 | 154 | if result.is_ok() { 155 | return Ok(()); 156 | } 157 | 158 | // Fallback to direct reboot command 159 | Command::new("reboot") 160 | .status() 161 | .map_err(|e| format!("Failed to execute reboot: {}", e))?; 162 | 163 | Ok(()) 164 | } 165 | -------------------------------------------------------------------------------- /goldboot/src/pivot_root.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | /// Check that the current system is a candidate for pivoting. 4 | #[cfg(target_os = "linux")] 5 | pub fn check_pivot_root() -> bool { 6 | false 7 | } 8 | 9 | fn exec_silent(exe: &str, args: &[&str]) { 10 | Command::new(exe) 11 | .args(args) 12 | .status() 13 | .expect("Failed to exec"); 14 | } 15 | 16 | /// Pivot the root mountpoint to a temporary directory so we can reimage the root 17 | /// partition on-the-fly. We have no intention of ever pivoting back, so it's OK 18 | /// to break stuff. Taken from: https://unix.stackexchange.com/a/227318. 19 | #[cfg(target_os = "linux")] 20 | pub fn pivot_root() { 21 | 22 | // Unmount everything we can 23 | exec_silent("umount", &["-a"]); 24 | 25 | // Create a temporary root 26 | exec_silent("mkdir", &["/tmp/tmproot"]); 27 | exec_silent("mount", &["-t", "tmpfs", "none", "/tmp/tmproot"]); 28 | exec_silent("mkdir", &["/tmp/tmproot/proc"]); 29 | exec_silent("mkdir", &["/tmp/tmproot/sys"]); 30 | exec_silent("mkdir", &["/tmp/tmproot/dev"]); 31 | exec_silent("mkdir", &["/tmp/tmproot/run"]); 32 | exec_silent("mkdir", &["/tmp/tmproot/usr"]); 33 | exec_silent("mkdir", &["/tmp/tmproot/var"]); 34 | exec_silent("mkdir", &["/tmp/tmproot/tmp"]); 35 | exec_silent("mkdir", &["/tmp/tmproot/oldroot"]); 36 | exec_silent("cp", &["-ax", "/bin", "/tmp/tmproot/"]); 37 | exec_silent("cp", &["-ax", "/etc", "/tmp/tmproot/"]); 38 | exec_silent("cp", &["-ax", "/sbin", "/tmp/tmproot/"]); 39 | exec_silent("cp", &["-ax", "/lib", "/tmp/tmproot/"]); 40 | exec_silent("cp", &["-ax", "/lib64", "/tmp/tmproot/"]); 41 | 42 | // Run pivot root 43 | exec_silent("mount", &["--make-rprivate", "/"]); 44 | exec_silent("pivot_root", &["/tmp/tmproot", "/tmp/tmproot/oldroot"]); 45 | exec_silent("mount", &["--move", "/oldroot/dev", "/dev"]); 46 | exec_silent("mount", &["--move", "/oldroot/proc", "/proc"]); 47 | exec_silent("mount", &["--move", "/oldroot/sys", "/sys"]); 48 | exec_silent("mount", &["--move", "/oldroot/run", "/run"]); 49 | 50 | // Clean up processes holding onto files in the old root 51 | exec_silent("systemctl", &["daemon-reexec"]); 52 | exec_silent("fuser", &["-mk", "/oldroot"]); 53 | 54 | // Lastly unmount the original root filesystem 55 | exec_silent("umount", &["/oldroot"]); 56 | } -------------------------------------------------------------------------------- /goldboot/src/registry/api/image.rs: -------------------------------------------------------------------------------- 1 | use goldboot_image::{ImageArch, ImageHandle}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Serialize, Deserialize)] 5 | pub struct ImageInfoResponse { 6 | pub version: u8, 7 | 8 | /// The total size of all blocks combined in bytes 9 | pub size: u64, 10 | 11 | /// Image creation time 12 | pub timestamp: u64, 13 | 14 | /// A copy of the name field from the config 15 | pub name: String, 16 | 17 | /// System architecture 18 | pub arch: ImageArch, 19 | } 20 | 21 | impl From for ImageInfoResponse { 22 | fn from(value: ImageHandle) -> Self { 23 | Self { 24 | version: value.primary_header.version, 25 | size: value.primary_header.size, 26 | timestamp: value.primary_header.timestamp, 27 | name: todo!(), 28 | arch: value.primary_header.arch, 29 | } 30 | } 31 | } 32 | 33 | #[derive(Serialize, Deserialize)] 34 | pub struct ImageListResponse { 35 | pub results: Vec, 36 | } 37 | -------------------------------------------------------------------------------- /goldboot/src/registry/api/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod image; 2 | 3 | pub struct RegistryTokenPermissions { 4 | // TODO 5 | } 6 | 7 | pub struct RegistryToken { 8 | /// The token value 9 | pub token: String, 10 | 11 | /// Whether the token value has been hashed with PBKDF2 12 | pub hashed: bool, 13 | 14 | /// Whether the token value has been encrypted with AES256 15 | pub encrypted: bool, 16 | 17 | /// A time-based second factor secret URL associated with the token 18 | pub totp_secret_url: Option, 19 | 20 | /// The expiration timestamp 21 | pub expiration: Option, 22 | 23 | /// The token's associated permissions 24 | pub permissions: RegistryTokenPermissions, 25 | } 26 | -------------------------------------------------------------------------------- /goldboot/src/registry/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | comment_width = 80 2 | imports_granularity = "Crate" 3 | reorder_imports = true 4 | unstable_features = true 5 | wrap_comments = true -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import { } }: 2 | 3 | with pkgs; 4 | 5 | mkShell rec { 6 | nativeBuildInputs = [ pkg-config cargo rustc rust-analyzer rustfmt clippy ]; 7 | buildInputs = [ cmake libclang openssl udev pango gtk4 ]; 8 | LD_LIBRARY_PATH = lib.makeLibraryPath buildInputs; 9 | } 10 | 11 | --------------------------------------------------------------------------------