random bloggings

/dev/blograndom

Who needs social media when you can dd if=/dev/brainfart of=/dev/blograndom !?

2026-09-13

arachnopress 1.0.65

  • Introducing LCARS-inspired palette themes
    • Thanks to the fine work of Matthew Leonawicz in the trekcolours palettes (MIT licensed)
    • Added LCARS_2357, LCARS_2369, LCARS_2375, and LCARS_2379 variants
    • Mapped RGB values to interface and syntax roles, added filled LCARS chrome, and tuned background opacity
    • The mappings need a bit more fine-tuning in some instances, but adding these has introduced a couple of useful CSS features that can be used to tune existing themes
  • There are many additional trekcolours palettes available, I'll look at integrating more in future versions

2026-09-11

VLC/tvOS samba config

Apparently VLC really doesn't like transitioning from a plain SMB session using server smb encrypt = if_required in [global] to an encrypted SMB share using server smb encrypt = required in [private].

The work-around for this is to use desired in [global] and continue using required in [private], so encryption is all or nothing and there's no renegotiation.

Now my LG Smart TV doesn't know how obsessed with 90s Star Trek I am (technically anything ≤2005, beyond that just isn't Star Trek. Also, the TV probably still knows!) - which reminds me, I must add an LCARS theme to arachnopress.

I also updated the smb.conf config file.

2026-09-10

Rockbox AAC-HE codec and Malazan CUE files

I've listened to the Malazan Book of the Fallen audiobooks on a few devices, and annoyingly they have small embedded 20-25 minute sections rather than full chapter breaks, let alone chapter titles - I think this is because they are quite old and there were chapter length limitations in some audiobook platforms back in the 2010s.

Additionally to that, many audio players (including Rockbox) don't even support the embedded section markers.

I decided to fix it with CUE files, which are supported by Rockbox and many enthusiast DAP devices, but bumped into a couple of other issues along the way.

  • Limitations (Rockbox, but may apply to other devices)
    1. Supports <99 CUE sections per file, the Malazan audiobooks are massive and all contain >100 sections
    2. AAC-HE (High-Efficiency) codec misinterprets some timing table entries, causing incorrect FF/RW seeking and jumps to CUE chapter positions
    3. 32-bit timing range limitation, high sample-rate means lower permitted maximum playback duration
  • Fixes / Work-arounds
    1. Chapter counts are closer to ~24, and the (>100) section boundries always have a boundry that starts where a chapter begins
      • Used the Audnexus API to find real Section/Chapter times and titles for the 10 Malazan audiobooks
      • Matched each section/chapter timestamp against the already embedded sections breaks (extracted with ffprobe)
      • Created a ".chapters.txt" mapping file for each audiobook with chapter names and the index number of the embedded start section
      • Created m4b2cue.sh which builds a CUE file (Rockbox compatible) for each audiobook assisted by the mapping files
    2. The AAC-LC (Low-Complexity) codec doesn't suffer from the timing table entry issue
      • Created m4b2aac.sh which uses ffmpeg to transcode the original AAC-HE files to AAC-LC
        • Parallel processing was implemented because transcoding is bound to a single core, running 10 audiobooks in parallel took my M2 Max macbook about 40 minutes with fans running full speed!
        • Ensured album-art and existing metadata was preserved in the transcoded files
        • Added options for bitrate and channels, with sane defaults if unset.
    3. m4b2aac.sh uses a fixed 24kHz sample rate, which keeps below the 32-bit timing range limitation, providing a max of ~49.7 hours

After dropping the CUE files alongside the AAC-LC .m4b files and enabling the CUE file support setting in the Rockbox menu, everything worked perfectly with accurate FF/RW, chapter navigation, and chapter titles. I've provided the finished CUE files here, and included the chapter mapping files just in case they might be useful.

I'll update this to include Novels of the Malazan Empire as soon as I purchase them from libro.fm.

Downloadmalazan_cue_chapters.zipReady to use CUE files for Malazan Book of the Fallen12 KiBSHA256 (rockbox/malazan_cue_chapters.zip) = aa9531e0635b3626f9bf82e1ec7a00edd548444a44bbb706a194163ac23a8abf

The next task is too see what can be fixed in the Rockbox source to remove these limitations . . .

Added to scripts article

These are POSIX sh scripts, and should be good templates for further (parallel) ffmpeg/ffprobe conversion/generator scripts.

Requirements are ffmpeg/ffprobe. m4b2cue.sh makes substantial use of jq.

  • m4b2aac.sh - Parallel batch Conversion of m4a/m4b to AAC-LC
  • m4b2cue.sh - Extract CUE file from m4a/m4b, optionally guided by Chapter metadata files

2026-09-06

Innioasis Y1 Rockbox

Innioasis Y1Y1 running Rockbox (AA for scale)rawโ†“

Flashed Rockbox to the Innioasis Y1 DAP . . .

The Innioasis Y1 is a relatively cheap android DAP, which seems to be based on mobile-type hardware - Importantly it has a relatively good speaker, which I believe is the sound-dampening ball filled type.

I bumped into it online whilst looking for a replacement Rockbox capable DAP to replace my ancient Sandisk Sansa Clip. I was also looking for something with an internal speaker, as I plan to use it for listening to audiobooks whilst going to sleep.

Flashing to Rockbox using rockbox-y1/rockbox was a painless process via the Mediatek Flash and Repair Utility - although I did end up doing it on debian, as macOS's raw USB access needed elevated privileges that I didn't want to provide (certainly not via sudo and a bunch of Python PyPI packages!)

The original firmware was pretty terrible in terms of sleep-timer configuration and fast-forward/rewind acceleration. Rockbox has both of these (and most everything else!) tuned/configurable to perfection - I have automatic resume and sleep timer enabled on boot, as well as bookmarking features.

The touch wheel works surprisingly well, and it looks like it can run Quake! Although, right now this is a dedicated Malazan Book of the Fallen device.

I recommend libro.fm for all your DRM-free audiobook needs.

2026-09-05

arachnopress 1.0.64

  • Enhancements to create/modify date handling
    • Added optional data-created and data-modified attributes to h2/h3 tags
    • Dates conditionally roll-up to parents and are used for better Atom feed ordering and article index sorting
    • A rule-based comparison mechanism is used for optimal handling of create/modify dates if/where provided

2026-09-04

arachnopress 1.0.63

  • Enhanced Atom feed with data-feed-sections article attribute values:
    • none (or unset), does not include article
    • h1, article title, plain-text summary, link to article
    • h2, "article title - section heading", link to section
    • h3, "article title - section heading", link to sub-section(s)

2026-09-03

arachnopress 1.0.62

  • Refined Atom 1.0 Feed support
  • โš›๏ธŽ button now has the correct mime-type (set for .atom files, and configured in httpd.conf example)

2026-09-02

arachnopress 1.0.61

  • Atom 1.0 Feed support added!
  • Article index browser-native tooltips with summary text and metadata
  • Article Creation/Updated dates now have default values and "auto" option
  • Theme mode selector configurable at build: off/exact/auto/light/dark

2026-08-30

Config files article

Added a config files article, as an archive for myself and maybe useful to a passing wanderer.

I think the universe wants me to join the drooling mass of smooth grey goo that is all TikTok and Meta-platform users, but I intend to persist into the next aeon by becoming such an obscenely supermassive black hole that, after a googol years of Hawking away on the cosmic back burner, my final evaporation crosses the conformal boundary as a Hawking point - thus remaining, in the weakest possible sense, available for the next universe.

2026-08-27

arachnopress 1.0.60

  • Added full article-pane size pop over viewers for image blocks

2026-08-25

Reading Tool

Porsche Design P8801Porsche Design Reading Tool P8801rawโ†“

Finally gave up struggling to read small-print . . .

  • Porsche Design Reading Tool P8801
    • Weight: ~16 g
    • Lens width: 48 mm
    • Lens height: ~21-22 mm
    • Bridge: ~21 mm
    • Temple: 150 mm
    • Overall width: ~126 mm
    • Front: stainless steel
    • Temples: high-tech polyamide / RXP
    • Lenses: polycarbonate (AR/UV)

Finding a decent pair of half-moon reading glasses that I can leave sat on the end of my nose all day turned out to be quite difficult until I found the Porsche Design "Reading Tool". I had to give up on my idea of actual half-moon lenses and gold plated frames for reading cosmic horror by candle light, but it still turned out well.

Apparently Porsche have been making this exact design for the last 20 years, which is pretty weird considering I could only find two videos of them on the less cosmic but certainly horrific YouTube. I can see why they stuck with it though, my first time needing glasses and I don't even notice they're there - I can actually read the tiny date window on my Seiko now, after forgetting it even had one!

2026-08-08

arachnopress 1.0.58

  • Image blocks needed a few enhancements, mainly alignment issues

arachnopress 1.0.59

  • It wasn't finished . . . ๐Ÿ˜‚

2026-08-07

Wristwatches

Seiko SPB315J1Prospex Heritage Turtle 1968 Re-Interpretationi (2022)rawโ†“

Aquired a new wristwatch . . .

  • Seiko's 6R35 movement
    • 70 hour power reserve
    • 21,600 vph / 3Hz / 6 bps
    • +25/-15 seconds daily (currently -10)
    • Durable and simple, but not particularly accurate
  • 200m water resistance
  • Screw-down crown
  • Super-Hard coating (DLC/Diamond-like carbon treated casing)
  • Unobtrusive date window between 4 and 5
  • Sapphire crystal, with underside AR coating

Very pleased with it, this was my first time adjusting a pin & collar bracelet, and I think the easiest so far to size - Lower end split pins need too much force (link tool / hammer) and higher end screw bar/tube systems always bother me due to tightening force and damaging the thread or screw-head (not forgetting decent screwdrivers).

arachnopress 1.0.57

  • I think it's finished . . .

Technical reference

arachnopress

Build a zero-JS static article site from hand-written HTML fragments.

DESCRIPTION

arachnopress reads articles/slug/article.html files and builds a single-page or multi-page static article site in site/. make build is the normal interface.

It runs in POSIX-like BSD, Linux, and macOS environments. Generated output is served as static files and may also be opened through file://. It requires no server-side processing.

FEATURES

  • File-backed code, download, and image blocks with raw links, checksums, image sizing, framing, alignment, text flow, and native enlarged-image popovers.
  • Pygments or Source-highlight syntax highlighting with escaped-source fallback and reduced span markup.
  • Minimal, hand-edited article fragments with arbitrary HTML; no Markdown or template pipeline.
  • Single-page or per-article output, optional unlisted articles, and HTML or extensionless internal URLs.
  • Opt-in Atom entries for articles or sections, with summaries, updated dates, and section/subsection links.
  • Responsive CSS navigation, sortable title and date indexes, and article creation and modification metadata.
  • Selectable upstream colour palettes with Exact, Auto, Light, and Dark modes.
  • SVG logo and favicon support, theme-aware inline SVG, and optional fixed or randomised SVG backdrops. A single Unicode symbol supplies the logo/favicon fallback when no SVG icon is configured.
  • An HTML and CSS interface without JavaScript, cookies, browser storage, or server-side processing.
  • Native theme, contact, and license popovers; marked GET contact requests for access-log collection.
  • POSIX-style sh and make builds for BSD, Linux, and macOS, with optional versioned release packaging.

DOWNLOADS

Downloadarachnopress_1.0.65.tar.gzgzipped source archive102 KiBSHA256 (arachnopress_1.0.65.tar.gz) = 7b9677c2aeba89836839b15a88bbfb6a72161bf1a73c34ae37dea31efd7be57a

SYNOPSIS

Run make build from the project root. It builds every source article and replaces site/. Supply settings through the environment or as make command-line variables.

Do not store source files in or run make from site/; each successful build replaces it.

REQUIREMENTS

Required utilities

The build requires a POSIX-like operating environment with sh, make, awk, sed, grep, sort, tr, date, dirname, pwd, printf, mktemp, mkdir, rm, wc, cat, cksum, chmod, mv, cp, and find.

The maintainer targets additionally require cmp, tar, and gzip support in tar. mktemp is widely available but is not specified by POSIX.

Optional utilities

pygmentize
Provides Pygments syntax highlighting. This is the default highlighter when the command is available.
source-highlight
Provides GNU Source-highlight syntax highlighting when selected.
sha256, sha256sum, shasum, or openssl
Provides SHA256 values for download blocks. The first successful implementation is used. Downloads remain usable without a checksum.

Browser features

The generated interface requires browser support only for HTML and CSS. It uses CSS :has(), :target, prefers-color-scheme, native details/summary, and the HTML popover API. The optional randomised article backdrop requires CSS random(); unsupported browsers retain the SVG's base fit. The bundled backdrop uses a centred cover fit.

SOURCE LAYOUT

Maintained files

Makefile
Defines build, release, and full, their defaults, and their explicit inclusion lists.
styles.css
Defines layout, themes, syntax colours, responsive behaviour, popovers, and generated block presentation. Targets copy it into their fresh site output but do not modify it.
THIRD_PARTY_NOTICES.txt
Identifies the upstream colour schemes, sources, authors, and licences. The license popover and optional Theme popover link to this file.
licenses/
Contains the retained third-party copyright and licence notices.
tools/build.sh
Discovers, validates, orders, and renders articles. It generates the selected page layout, optional Atom feed, favicon.svg, automatic-theme rules, and embedded navigation rules in the selected build root.
tools/build-profile.sh
Stages, builds, publishes, cleans, and packages target output.
tools/theme-menu.html
Supplies theme controls and optional data-auto-dark and data-auto-light pair mappings. Its IDs are reserved as page anchors.
tools/license.txt
Supplies the authoritative static project-license text. Its first non-blank line is the displayed title.
tools/html-fragment.outlang
Configures Source-highlight to emit an HTML fragment.
articles/<slug>/article.html
Supplies one article. The directory name is the article slug and must match the article element ID.
articles/<slug>/*
Supplies article-local code, download, and image files. These paths are copied below site/articles/ and linked from the page.

The example tree shows maintained source and make build output. site/ contains deployed files and copied articles. The example contains only the arachnopress article.

Example project and build outputrawโ†“
.
|-- Makefile
|-- README.arachnopress
|-- THIRD_PARTY_NOTICES.txt
|-- articles
|   `-- arachnopress
|       |-- article.html
|       `-- ...
|-- licenses
|   |-- mit.txt
|   `-- oksolar-cc0.txt
|-- site
|   |-- THIRD_PARTY_NOTICES.txt
|   |-- articles
|   |   `-- arachnopress
|   |       |-- article.html
|   |       `-- ...
|   |-- favicon.svg
|   |-- feed.atom
|   |-- index.html
|   |-- licenses
|   |   |-- mit.txt
|   |   `-- oksolar-cc0.txt
|   |-- styles.css
|   `-- theme-auto.css
|-- styles.css
`-- tools
    |-- build-profile.sh
    |-- build.sh
    |-- html-fragment.outlang
    |-- license.txt
    `-- theme-menu.html

BUILD MODEL

make build copies the configured files and articles into a private staging tree, renders the complete site, then replaces site/. A failure before publication preserves the previous output.

site/ is generated output. All targets replace the same directory and must not run concurrently. The maintainer release targets also manage the generator download marker and versioned archive.

ARTICLE FORMAT

Root metadata

Each article begins with a one-line article element. Its class list must include article. Its id must equal the containing directory name. data-title is required. Use double-quoted values for attributes read by the generator.

data-created and data-modified accept a value beginning with a valid YYYY-MM-DD date or auto:

  • data-created: missing, empty, or auto uses the current UTC build date.
  • data-modified: missing or empty uses the resolved creation date; auto uses the build date.
  • Explicit dates remain unchanged. Use explicit dates on published articles and dated headings to keep metadata and ordering stable.

data-listed defaults to true and accepts only true or false. SITE_MODE_UNLISTED=false excludes articles marked data-listed=false from generated HTML and navigation in either output mode. SITE_MODE_UNLISTED=true enables unlisted output.

data-feed-sections selects none, h1, h2, or h3. Unset or empty means none. See Atom feed modes under NAVIGATION AND THEMES.

Unlisted output:

  • Single-page: embedded after listed articles, absent from article indexes, and accessible through article, section, and subsection fragments.
  • Multi-page: written as slug.html, absent from listed-page indexes, and included only in its own index. Its document has noindex, nofollow.
  • Single-page articles share index.html and have no separate robots directive.
  • Unlisted output remains publicly accessible. Targets still copy included article directories to site/articles/.
  • At least one listed article is required. Multi-page mode reserves index.
articles/getting-started/article.htmlComplete example articleraw
<article class="article" id="getting-started" data-title="Getting Started" data-created="2026-07-09" data-modified="2026-07-09" data-feed-sections="h1">
  <header class="article-header">
    <p class="kicker">Guide</p>
    <h1>Getting Started</h1>
    <p class="summary">
      A short article built from one editable HTML fragment.
    </p>
  </header>

  <section>
    <h2>Example</h2>
    <p>Article text is normal HTML.</p>
    <h3>Subsection</h3>
    <p>Plain one-line h3 headings appear under their preceding section.</p>
  </section>
</article>

Header metadata

The source header contains an h1 and summary. The build inserts Created and a time element before its closing tag. Updated is the newest resolved article or heading date and appears when it differs from Created. Unlisted articles are prefixed with Unlisted; fields use middle-dot separators.

Indexed headings

Use h2 for major sections and h3 for genuine subdivisions. Indexable headings contain plain text and close on the same source line. An h3 must follow an h2. The build preserves a valid explicit ID or derives a unique ID from the heading and article.

Article, heading, theme, generated control, and generated block IDs share one collision registry. Explicit IDs and slugs may contain ASCII letters, digits, dots, underscores, and hyphens.

Every source h2 and h3 is indexed. IDs on other author-supplied elements are preserved but are not registered; keep them unique and clear of generated IDs.

Indexable headings may use data-created and data-modified. Values follow the article-date format and may use auto. Their resolved dates contribute to Updated metadata, modification ordering, and Atom dates. They do not reorder section or subsection indexes.

Indexed heading formsraw
<h2 data-created="2026-07-09">BUILD</h2>
<h3>Requirements</h3>
<h3 id="custom-subsection" data-modified="2026-07-10">Explicit Subsection Anchor</h3>
<h2 id="custom-anchor" data-created="2026-07-09" data-modified="2026-07-10">Explicit Anchor</h2>

GENERATED BLOCKS

Generated markers are empty, file-backed elements contained on one source line. data-src is relative to the article directory. Missing or invalid sources render visible missing blocks. Use double-quoted marker attributes.

File paths (data-src, SITE_ICON_PATH, SITE_BACKGROUND_PATH):

  • Relative, slash-separated paths; each component contains only ASCII letters, digits, dots, underscores, and hyphens.
  • Empty, . and .. components and symlinks are rejected.

Code blocks

  • data-title: display title. data-note: header qualifier.
  • data-open="false": initially closed.
  • data-header="false": always visible, without a summary.
  • data-footer: true or false overrides the default. Normal blocks hide the footer for source line counts at or below CODE_FOOTER_LINES; headerless blocks hide it unless explicitly enabled.

Raw links and sizes refer to the original file. Only the rendered code is HTML-escaped.

Code block markersKeep each marker on one lineraw
<pre class="code-block" data-src="src/example.c" data-title="example.c" data-note="A short C example" data-lang="c"></pre>
<pre class="code-block" data-src="build.sh" data-lang="sh" data-open="false" data-footer="false"></pre>
<pre class="code-block" data-src="output.txt" data-lang="text" data-header="false"></pre>
<pre class="code-block" data-src="output.txt" data-title="output.txt" data-lang="text" data-header="false" data-footer="true"></pre>

Download blocks

A download marker names exactly one file. data-title changes only its display title and data-note adds a qualifier. The link continues to target data-src. The block shows the raw size and, when a supported checksum program is available, its SHA256. The obsolete data-downloads attribute is rejected; use one data-src marker per file.

The generator article has exactly one marker carrying data-release="generator". The release and full targets replace that complete line with the validated current archive name; a normal build leaves it unchanged.

Download block markersOne source file per markerraw
<div class="article-downloads" data-src="release.tar.gz" data-title="release.tar.gz" data-note="Source archive"></div>

Image blocks

  • data-alt: alternative text. data-caption: caption.
  • data-title and data-note: header labels.
  • data-open and data-header: as for code blocks.
  • data-footer: shown with a normal header unless false; a headerless image requires true to show it.
  • data-scale caps inline size: small (20rem), medium (32rem), large (48rem), or full (article width, default). Images retain their aspect ratio, do not upscale, and remain within the viewport.
  • data-border: full (default), fit, or none. fit shrink-wraps the block with one width for its header, image border, and footer. none removes the frame, uses the opaque article background, and suppresses both bars.
  • data-align: start, center (default), or end.
  • Other scale, border, and alignment values are fatal.
  • Images use lazy loading and asynchronous decoding.

Text flow:

  • Above 760px, following prose and text lists may flow beside start- or end-aligned fit or borderless blocks, capped at 55% of the containing section's width and by the configured size limit.
  • Headings, generated blocks, preformatted blocks, tables, horizontal rules, consecutive images, and section ends clear the flow.
  • Centered, full-frame, and narrower layouts keep images on their own row.

Image viewer:

  • Selecting an image opens a themed viewer over the article pane.
  • Intrinsic size and aspect ratio are retained, scaling down to fit.
  • The enlarged image, X button, Escape, or light dismissal closes it.
  • Missing images have no viewer.
Image block markersraw
<figure class="image-block" data-src="images/diagram.png" data-title="Diagram" data-note="Article-local image path" data-alt="Build flow diagram" data-caption="Article-local image"></figure>
<figure class="image-block" data-src="images/icon.png" data-title="Icon" data-alt="Small icon" data-scale="small" data-border="fit" data-align="start" data-header="false" data-footer="true"></figure>
<figure class="image-block" data-src="images/plain.png" data-alt="Borderless image" data-scale="small" data-border="none" data-align="end"></figure>

Missing sources

A missing or invalid source renders a missing block. A non-empty or multiline marker is fatal.

SYNTAX HIGHLIGHTING

HIGHLIGHTER selects pygments, source-highlight, or none. Use an explicit data-lang for predictable lexer selection, or omit it for automatic selection.

Pygments

Pygments is the default. An omitted data-lang requests filename inference. data-lang="auto" may also guess from content.

Source-highlight

Source-highlight uses tools/html-fragment.outlang. An omitted or automatic language lets the program infer its input language.

Fallback and optimisation

A missing program, unknown lexer, failed invocation, missing output definition, or empty highlighted result for non-empty input falls back to escaped raw source for that block. HIGHLIGHTER=none always uses that path.

Successful highlighted output is filtered to unwrap whitespace-only Pygments span.w and Source-highlight span.sh-normal elements. Their whitespace remains in the preformatted code content.

NAVIGATION AND THEMES

Article and section selection

The build writes title, creation-date, and modification-date order lists. ARTICLE_ORDER selects the initial list; CSS radio controls select another.

In single-page mode, URL fragments and CSS :target/:has() select articles, sections, and subsections. The first listed article is visible without a target. index.html embeds the generated navigation rules.

In multi-page mode, index.html contains the first listed article and each article has slug.html. Each file contains one article, its section index, and its generated navigation rules. A slug page title is its article title followed by SITE_TITLE; the index page title is SITE_TITLE.

Title order is case-insensitive by title, then slug. Creation order uses the resolved article creation date. Modification order uses the newest resolved article or heading date. Date orders are newest first, then slug. Article-title links provide the article summary and resolved Created and Updated dates as native hover text, with the dates on the final line. If a summary cannot be reduced to clean text, the hover text contains only the dates.

Responsive index

Wide landscape layouts place article and section navigation in a vertical left pane. Narrow portrait layouts use independent horizontal article, section, and conditional subsection rows. Short and narrow layouts hide descriptive build metadata while retaining the generator identity, Contact, and license controls.

Theme state

DEFAULT_THEME selects the initial exact variant. DEFAULT_THEME_MODE selects the initial mode:

  • off: omit the mode control; apply DEFAULT_THEME unchanged.
  • exact: apply the selected theme unchanged.
  • auto: select the mapped variant using the browser preference.
  • light or dark: select the corresponding mapped variant.

Every value except off renders all four choices. An unmapped theme remains fixed in every mode.

DEFAULT_THEME_SELECTOR enables the selector from tools/theme-menu.html. Single-page selections span all articles until reload. Multi-page selections reset when another page loads. The mode control is independent of the Theme selector.

Light and dark pairs are the data-auto-light and data-auto-dark attributes in tools/theme-menu.html. Edit those attributes to change a pairing.

tools/theme-menu.htmlComplete theme menurawโ†“
      <div class="theme-menu" id="theme-popover" popover>
        <div class="theme-head">
          <h2>Theme</h2>
          <button class="theme-close" type="button" popovertarget="theme-popover" popovertargetaction="hide" aria-label="Close theme menu">X</button>
        </div>
        <form class="theme-list" aria-label="Theme">
          <fieldset>
            <legend>Solarized</legend>
            <input type="radio" name="theme" id="theme-solarized-dark" data-auto-dark="theme-solarized-dark" data-auto-light="theme-solarized-light" checked>
            <label for="theme-solarized-dark">Dark</label>
            <input type="radio" name="theme" id="theme-solarized-light" data-auto-dark="theme-solarized-dark" data-auto-light="theme-solarized-light">
            <label for="theme-solarized-light">Light</label>
          </fieldset>
          <fieldset>
            <legend>Selenized</legend>
            <input type="radio" name="theme" id="theme-selenized-dark" data-auto-dark="theme-selenized-dark" data-auto-light="theme-selenized-light">
            <label for="theme-selenized-dark">Dark</label>
            <input type="radio" name="theme" id="theme-selenized-black" data-auto-dark="theme-selenized-black" data-auto-light="theme-selenized-white">
            <label for="theme-selenized-black">Black</label>
            <input type="radio" name="theme" id="theme-selenized-light" data-auto-dark="theme-selenized-dark" data-auto-light="theme-selenized-light">
            <label for="theme-selenized-light">Light</label>
            <input type="radio" name="theme" id="theme-selenized-white" data-auto-dark="theme-selenized-black" data-auto-light="theme-selenized-white">
            <label for="theme-selenized-white">White</label>
          </fieldset>
          <fieldset>
            <legend>OKSolar</legend>
            <input type="radio" name="theme" id="theme-oksolar-dark" data-auto-dark="theme-oksolar-dark" data-auto-light="theme-oksolar-light">
            <label for="theme-oksolar-dark">Dark</label>
            <input type="radio" name="theme" id="theme-oksolar-light" data-auto-dark="theme-oksolar-dark" data-auto-light="theme-oksolar-light">
            <label for="theme-oksolar-light">Light</label>
          </fieldset>
          <fieldset>
            <legend>Gruvbox Material</legend>
            <input type="radio" name="theme" id="theme-gruvbox-material-hard-dark" data-auto-dark="theme-gruvbox-material-hard-dark" data-auto-light="theme-gruvbox-material-hard-light">
            <label for="theme-gruvbox-material-hard-dark">Hard Dark</label>
            <input type="radio" name="theme" id="theme-gruvbox-material-hard-light" data-auto-dark="theme-gruvbox-material-hard-dark" data-auto-light="theme-gruvbox-material-hard-light">
            <label for="theme-gruvbox-material-hard-light">Hard Light</label>
            <input type="radio" name="theme" id="theme-gruvbox-material-medium-dark" data-auto-dark="theme-gruvbox-material-medium-dark" data-auto-light="theme-gruvbox-material-medium-light">
            <label for="theme-gruvbox-material-medium-dark">Medium Dark</label>
            <input type="radio" name="theme" id="theme-gruvbox-material-medium-light" data-auto-dark="theme-gruvbox-material-medium-dark" data-auto-light="theme-gruvbox-material-medium-light">
            <label for="theme-gruvbox-material-medium-light">Medium Light</label>
            <input type="radio" name="theme" id="theme-gruvbox-material-soft-dark" data-auto-dark="theme-gruvbox-material-soft-dark" data-auto-light="theme-gruvbox-material-soft-light">
            <label for="theme-gruvbox-material-soft-dark">Soft Dark</label>
            <input type="radio" name="theme" id="theme-gruvbox-material-soft-light" data-auto-dark="theme-gruvbox-material-soft-dark" data-auto-light="theme-gruvbox-material-soft-light">
            <label for="theme-gruvbox-material-soft-light">Soft Light</label>
          </fieldset>
          <fieldset>
            <legend>Tomorrow</legend>
            <input type="radio" name="theme" id="theme-tomorrow" data-auto-dark="theme-tomorrow-night" data-auto-light="theme-tomorrow">
            <label for="theme-tomorrow">Light</label>
            <input type="radio" name="theme" id="theme-tomorrow-night" data-auto-dark="theme-tomorrow-night" data-auto-light="theme-tomorrow">
            <label for="theme-tomorrow-night">Night</label>
            <input type="radio" name="theme" id="theme-tomorrow-eighties" data-auto-dark="theme-tomorrow-eighties" data-auto-light="theme-tomorrow">
            <label for="theme-tomorrow-eighties">Eighties</label>
            <input type="radio" name="theme" id="theme-tomorrow-blue" data-auto-dark="theme-tomorrow-blue" data-auto-light="theme-tomorrow">
            <label for="theme-tomorrow-blue">Blue</label>
            <input type="radio" name="theme" id="theme-tomorrow-bright" data-auto-dark="theme-tomorrow-bright" data-auto-light="theme-tomorrow">
            <label for="theme-tomorrow-bright">Bright</label>
          </fieldset>
          <fieldset>
            <legend>Nord</legend>
            <input type="radio" name="theme" id="theme-nord">
            <label for="theme-nord">Dark</label>
          </fieldset>
          <fieldset>
            <legend>Everforest</legend>
            <input type="radio" name="theme" id="theme-everforest-hard-dark" data-auto-dark="theme-everforest-hard-dark" data-auto-light="theme-everforest-hard-light">
            <label for="theme-everforest-hard-dark">Hard Dark</label>
            <input type="radio" name="theme" id="theme-everforest-hard-light" data-auto-dark="theme-everforest-hard-dark" data-auto-light="theme-everforest-hard-light">
            <label for="theme-everforest-hard-light">Hard Light</label>
            <input type="radio" name="theme" id="theme-everforest-dark" data-auto-dark="theme-everforest-dark" data-auto-light="theme-everforest-light">
            <label for="theme-everforest-dark">Medium Dark</label>
            <input type="radio" name="theme" id="theme-everforest-light" data-auto-dark="theme-everforest-dark" data-auto-light="theme-everforest-light">
            <label for="theme-everforest-light">Medium Light</label>
            <input type="radio" name="theme" id="theme-everforest-soft-dark" data-auto-dark="theme-everforest-soft-dark" data-auto-light="theme-everforest-soft-light">
            <label for="theme-everforest-soft-dark">Soft Dark</label>
            <input type="radio" name="theme" id="theme-everforest-soft-light" data-auto-dark="theme-everforest-soft-dark" data-auto-light="theme-everforest-soft-light">
            <label for="theme-everforest-soft-light">Soft Light</label>
          </fieldset>
          <fieldset>
            <legend>Edge</legend>
            <input type="radio" name="theme" id="theme-edge-dark" data-auto-dark="theme-edge-dark" data-auto-light="theme-edge-light">
            <label for="theme-edge-dark">Dark</label>
            <input type="radio" name="theme" id="theme-edge-aura" data-auto-dark="theme-edge-aura" data-auto-light="theme-edge-light">
            <label for="theme-edge-aura">Aura</label>
            <input type="radio" name="theme" id="theme-edge-neon" data-auto-dark="theme-edge-neon" data-auto-light="theme-edge-light">
            <label for="theme-edge-neon">Neon</label>
            <input type="radio" name="theme" id="theme-edge-aura-dim" data-auto-dark="theme-edge-aura-dim" data-auto-light="theme-edge-light">
            <label for="theme-edge-aura-dim">Aura Dim</label>
            <input type="radio" name="theme" id="theme-edge-light" data-auto-dark="theme-edge-dark" data-auto-light="theme-edge-light">
            <label for="theme-edge-light">Light</label>
          </fieldset>
          <fieldset>
            <legend>Sonokai</legend>
            <input type="radio" name="theme" id="theme-sonokai">
            <label for="theme-sonokai">Default</label>
            <input type="radio" name="theme" id="theme-sonokai-atlantis">
            <label for="theme-sonokai-atlantis">Atlantis</label>
            <input type="radio" name="theme" id="theme-sonokai-andromeda">
            <label for="theme-sonokai-andromeda">Andromeda</label>
            <input type="radio" name="theme" id="theme-sonokai-shusia">
            <label for="theme-sonokai-shusia">Shusia</label>
            <input type="radio" name="theme" id="theme-sonokai-maia">
            <label for="theme-sonokai-maia">Maia</label>
            <input type="radio" name="theme" id="theme-sonokai-espresso">
            <label for="theme-sonokai-espresso">Espresso</label>
          </fieldset>
          <fieldset>
            <legend>Spring Night</legend>
            <input type="radio" name="theme" id="theme-spring-night">
            <label for="theme-spring-night">Normal</label>
            <input type="radio" name="theme" id="theme-spring-night-high-contrast">
            <label for="theme-spring-night-high-contrast">High Contrast</label>
          </fieldset>
          <fieldset>
            <legend>Ayu</legend>
            <input type="radio" name="theme" id="theme-ayu-dark" data-auto-dark="theme-ayu-dark" data-auto-light="theme-ayu-light">
            <label for="theme-ayu-dark">Dark</label>
            <input type="radio" name="theme" id="theme-ayu-mirage" data-auto-dark="theme-ayu-mirage" data-auto-light="theme-ayu-light">
            <label for="theme-ayu-mirage">Mirage</label>
            <input type="radio" name="theme" id="theme-ayu-light" data-auto-dark="theme-ayu-dark" data-auto-light="theme-ayu-light">
            <label for="theme-ayu-light">Light</label>
          </fieldset>
          <fieldset>
            <legend>Catppuccin</legend>
            <input type="radio" name="theme" id="theme-catppuccin-latte" data-auto-dark="theme-catppuccin-mocha" data-auto-light="theme-catppuccin-latte">
            <label for="theme-catppuccin-latte">Latte</label>
            <input type="radio" name="theme" id="theme-catppuccin-frappe" data-auto-dark="theme-catppuccin-frappe" data-auto-light="theme-catppuccin-latte">
            <label for="theme-catppuccin-frappe">Frappe</label>
            <input type="radio" name="theme" id="theme-catppuccin-macchiato" data-auto-dark="theme-catppuccin-macchiato" data-auto-light="theme-catppuccin-latte">
            <label for="theme-catppuccin-macchiato">Macchiato</label>
            <input type="radio" name="theme" id="theme-catppuccin-mocha" data-auto-dark="theme-catppuccin-mocha" data-auto-light="theme-catppuccin-latte">
            <label for="theme-catppuccin-mocha">Mocha</label>
          </fieldset>
          <fieldset>
            <legend>Rose Pine</legend>
            <input type="radio" name="theme" id="theme-rose-pine" data-auto-dark="theme-rose-pine" data-auto-light="theme-rose-pine-dawn">
            <label for="theme-rose-pine">Main</label>
            <input type="radio" name="theme" id="theme-rose-pine-moon" data-auto-dark="theme-rose-pine-moon" data-auto-light="theme-rose-pine-dawn">
            <label for="theme-rose-pine-moon">Moon</label>
            <input type="radio" name="theme" id="theme-rose-pine-dawn" data-auto-dark="theme-rose-pine" data-auto-light="theme-rose-pine-dawn">
            <label for="theme-rose-pine-dawn">Dawn</label>
          </fieldset>
          <p class="theme-third-party">Theme sources and licences: <a href="THIRD_PARTY_NOTICES.txt">third-party notices</a></p>
        </form>
      </div>

Atom feed

SITE_FEED=true writes feed.atom, feed discovery metadata, and a text-form Atom link before the Theme control. Listed articles opt in through data-feed-sections:

none
No entries. Also the default when unset or empty.
h1
One entry with the article title, link, and plain-text summary.
h2
One entry per h2, titled "Article title - Section heading", with a link to that section instead of the article summary.
h3
As h2, with a linked list of its h3 headings in each entry body.

Feed rules:

  • h2 and h3 fall back to h1 without h2 headings. In h3 mode, an h2 without child headings has the same content as h2 mode.
  • Section bodies contain generated HTML links, not article prose.
  • Unlisted articles never enter the feed. Other mode values are fatal.
  • An h1 entry uses the newest article or heading date. An h2 entry uses its data-modified, then its data-created, then the article's resolved modification date.
  • A dated h3 updates its parent entry when newer. An undated h3 has no effect. Article-level auto rolls into h1 entries; in section modes it is only the fallback for an undated h2. Feed dates use midnight UTC.
  • Article groups are ordered by their newest entry. Sections and linked subsections retain source order. An empty feed uses the UTC build date.
  • The feed identifies the generator and uses the versioned site favicon.
  • Links follow SITE_MODE and SITE_URL_STYLE. IDs remain SITE_URL/#article-id or SITE_URL/#section-id, independent of dates and output mode.
  • Explicit heading IDs are optional and preserve identity across renames and reordering of duplicate headings. New h2 headings create entries; section edits and added h3 links retain the parent entry ID.
  • Enabling section entries introduces existing sections as new entries. Revisions retain their IDs; read/unread state is reader-controlled.

Branding and backdrop

SITE_ICON supplies the Unicode header logo and generated favicon when SITE_ICON_PATH is empty. SITE_ICON_PATH supplies an SVG copied unchanged as the favicon and used as the header logo. SITE_ICON_PATH_THEME=true embeds the header copy and maps its colour-out and colour-in classes to the current article background and title colours. See Custom site icon under BUILD for class assignment.

SITE_BACKGROUND enables a fixed article-pane SVG backdrop. SITE_BACKGROUND_RANDOM lets supporting browsers select its scale and crop with CSS random() when loading the document. Other browsers retain the SVG's base fit. The bundled SVG uses a centred cover crop. Its colour-out class is not painted; colour-in uses the panel background colour at 25% opacity. Code, download, and image block surfaces remain opaque.

CONTACT AND LICENSE

Contact request

Contact opens a native popover:

  • Reply address: optional, at most 254 characters.
  • Message: required, at most 500 characters.
  • Submission: GET to the current page, targeting contact-confirmation.

The first query item is a lowercase SITE_TITLE identifier followed by _contact=1. Runs outside letters, digits, dots, underscores, and hyphens become one underscore; edge underscores are removed. Identifiers without ASCII letters or digits become arachnopress. Defaults are arachnopress_contact=1 for release and arachnogoat_contact=1 for build and full.

Contact request shapeValues are URL-encoded by the browserraw
Browser URL with SITE_TITLE="Example Site":
https://host.example/?example_site_contact=1&reply=operator%40example.com&message=Short+message#contact-confirmation

HTTP request target logged by a server that retains query strings:
GET /?example_site_contact=1&reply=operator%40example.com&message=Short+message

Contact privacy

HTTPS encrypts the request in transit. The address and message remain in the URL, browser history, and logs that record the query string. Do not submit confidential information. The static site provides no separate message delivery or storage.

The operator extracts and processes marked requests from the server log. arachnopress provides no log-processing component.

Project license

The license popover HTML-escapes and embeds tools/license.txt. Its first non-blank line is the title. THIRD_PARTY_NOTICES.txt maps bundled colour palettes to notices below licenses/.

TARGETS

build
Routine site-generation target. Replaces site/ with the deployment files, every source article, and newly generated output. Leaves release markers and archives unchanged.
release
Maintainer target for a generator release. Builds only the arachnopress article, marks its archive download as missing, and replaces site/. It creates the versioned archive in the project root, removes all arachnopress_*.tar.gz source article archives, and updates the source marker.
full
Optional maintainer target that installs the matching root release archive in a full staged site. It replaces site/, removes all arachnopress_*.tar.gz source article archives, and updates the source marker.

No clean, install, serve, or watch target is defined.

VARIABLES

RELEASE_SITE_ENV defines release site defaults. PUBLIC_SITE_ENV defines build and full site defaults. BUILD_ENV defines shared defaults. Environment and command-line make values override them.

Site identity

SITE_TITLE
Page title and displayed brand. It also forms the contact request marker prefix. Defaults: arachnopress for release and direct tools/build.sh use; arachnogoat for build and full.
SITE_FEED
true writes feed.atom, its discovery metadata, and a header link. Listed articles opt in with data-feed-sections. false omits them. Defaults: false for release and direct tools/build.sh use; true for build and full.
SITE_URL
Canonical site root used for Atom links. SITE_FEED=true requires an absolute HTTP or HTTPS URL without a query or fragment. A trailing slash is removed. Defaults: empty for release and direct tools/build.sh use; https://arachnogoat.com for build and full.
SITE_AUTHOR
Atom feed author. Defaults: SITE_TITLE for release and direct tools/build.sh use; musol@arachnogoat.com for build and full.
SITE_ICON
Character shown beside SITE_TITLE and rendered into the generated SVG favicon. Defaults: U+4DD6 for release and direct tools/build.sh use; U+4DEA for build and full.
SITE_ICON_COLOUR
Optional fixed colour for the Unicode header icon and generated favicon. The value must be #rgb or #rrggbb. When empty, the header icon follows the current article-title colour and the generated favicon uses the SVG default text fill. It is unused when SITE_ICON_PATH is set. Default: empty.
SITE_ICON_PATH
Optional relative path to an SVG in the selected build tree. The build copies it unchanged to site/favicon.svg and uses it beside SITE_TITLE. SITE_ICON and SITE_ICON_COLOUR are then unused. The path must not name the generated favicon.svg. Defaults: empty for release; articles/arachnopress/arachnogoatsundual.svg for build and full. Direct tools/build.sh execution defaults to empty. Restore the text icon with SITE_ICON_PATH= and SITE_ICON_PATH_THEME=false.
SITE_ICON_PATH_THEME
true embeds the SITE_ICON_PATH SVG in the header and maps its colour-out and colour-in classes to the current article background and title colours. The favicon remains an unchanged copy. true requires SITE_ICON_PATH. false uses the SVG as an external header image. Defaults: false for release and direct tools/build.sh use; true for build and full.
SITE_BACKGROUND
Article-pane backdrop. true embeds the SITE_BACKGROUND_PATH SVG. The bundled SVG uses a centred cover crop; a replacement's root SVG attributes determine its base fit. Defaults: false for release and direct tools/build.sh execution; true for build and full.
SITE_BACKGROUND_RANDOM
Backdrop crop. true lets browsers with CSS random() support select a scale and crop on page load; unsupported browsers retain the base fit. false always uses the base fit. Default: true.
SITE_BACKGROUND_PATH
Relative path to the backdrop SVG in the selected build tree. When SITE_BACKGROUND=true, the file must define colour-out and colour-in classes. The former is not painted; the latter uses the active theme's panel background colour at 25% opacity. Default: articles/arachnopress/arachnogoatsundual.svg. Use preserveAspectRatio="xMidYMid slice" for a centred cover fit.
GENERATOR_LABEL
Maintainer setting for the generator name displayed in site metadata and the Atom feed. Default: arachnopress.
GENERATOR_VERSION
Maintainer setting displayed after GENERATOR_LABEL and used in release archive names and Atom generator metadata. It contains dot-separated digits. Default: 1.0.64.

Navigation and rendering

DEFAULT_THEME
Exact input ID from tools/theme-menu.html, with or without the theme- prefix. Defaults: solarized-dark for release and direct tools/build.sh use; everforest-hard-dark for build and full.
DEFAULT_THEME_MODE
Theme mode. off omits the control and applies DEFAULT_THEME unchanged. exact, auto, light, or dark render all four choices and select that initial mode. Auto follows the browser preference; Light and Dark use the selected theme's mapping. An unmapped theme remains fixed. Defaults: off for release and direct tools/build.sh use; exact for build and full.
DEFAULT_THEME_SELECTOR
Theme selector state. true renders the selector in either output mode; false fixes the selection to DEFAULT_THEME. Default: true.
SITE_MODE
Output layout. single-page writes all generated articles to index.html. multi-page writes index.html plus one slug.html per article. Default: single-page.
SITE_MODE_UNLISTED
Unlisted article generation. true enables unlisted output using the selected SITE_MODE; false excludes unlisted articles from generated HTML and navigation. Defaults: false for release and direct tools/build.sh use; true for build and full.
SITE_URL_STYLE
Page-link format. html retains .html links. extensionless keeps generated .html files but uses slug and ./ for multi-page navigation, home links, and contact actions. Defaults: html for release and direct tools/build.sh use; extensionless for build and full. Extensionless output requires a matching web-server rewrite. Single-page output is unchanged.
ARTICLE_ORDER
Initial order: title, created, or modified. Default: title.
HIGHLIGHTER
pygments, source-highlight, or none. Default: pygments.
CODE_FOOTER_LINES
Non-negative line threshold for automatically hiding code block footers. Default: 23.

ENVIRONMENT

PATH
Locates required utilities and optional highlighters and checksum programs.
TMPDIR
Parent for private static-site-build.* rendering directories. Default: /tmp. Target staging uses .site-build.* below the project root.

LC_ALL is set to C. SOURCE_HIGHLIGHT_DATADIR is unset so Source-highlight uses its installed data files and the project output definition.

BUILD

Routine site build

make build is the normal target. Run it from the project root after changing articles, assets, styles, tool inputs, or build settings.

Routine build profilesRun from the project rootraw
make build

# Per-article pages with system light/dark selection and no theme menu.
make build SITE_MODE=multi-page DEFAULT_THEME_MODE=auto \
    DEFAULT_THEME_SELECTOR=false

# Per-article files for file:// navigation, using a fixed theme.
make build SITE_MODE=multi-page SITE_URL_STYLE=html \
    DEFAULT_THEME=solarized-dark DEFAULT_THEME_MODE=off \
    DEFAULT_THEME_SELECTOR=false

# Published articles only, without theme controls or an article backdrop.
make build SITE_MODE_UNLISTED=false SITE_BACKGROUND=false \
    DEFAULT_THEME_MODE=off DEFAULT_THEME_SELECTOR=false

# Override the public Atom feed identity.
make build SITE_FEED=true SITE_URL=https://example.com \
    SITE_AUTHOR='Example Author'

Custom site icon

Store the SVG below an included article. For theme colouring, assign colour-out and colour-in classes to its drawable elements or groups. Edit the class attributes directly or with Inkscape's XML Editor. Restore the Unicode icon with SITE_ICON_PATH= and SITE_ICON_PATH_THEME=false.

Custom site icon buildReplace article-slug after adding an SVGraw
# Run each command separately from the project root.
# Custom SVG after adding the required classes.
make build SITE_ICON_PATH=articles/article-slug/site-icon.svg \
    SITE_ICON_PATH_THEME=true

# Restore the Unicode icon.
make build SITE_ICON_PATH= SITE_ICON_PATH_THEME=false

Generator release

Maintainers changing the generator update GENERATOR_VERSION and run make release. This builds only the arachnopress article and creates the root archive. Copy that archive into articles/arachnopress/, then use make build for a normal site. The optional make full target performs the archive integration within its staged output.

Highlighter variants

HIGHLIGHTER=none selects escaped source. Select either installed highlighter explicitly to test its output.

Highlighter buildsRun separately from the project rootraw
# Run each command separately from the project root.
make build HIGHLIGHTER=none
make build HIGHLIGHTER=pygments
make build HIGHLIGHTER=source-highlight

REBUILD AND RECOVERY

  • Re-run make build after changing maintained input. Stylesheet and favicon cache tokens change with their resources.
  • After failure or interruption, correct the diagnostic and rerun the target. Normal failure and signal handling remove unpublished temporary files and preserve the previous site before publication.
  • After an untrappable termination, wait until no build is running. Inspect .site-build.* below the project root and static-site-build.* below TMPDIR before removing leftovers.

Remove generated output

No clean target is defined. Remove site/ without changing maintained source. Release replaces the current-version root archive but does not remove older root archives. Remove obsolete root archives by exact name.

Remove generated outputRun from the project rootraw
# Run from the project root when no build is running.
rm -rf site

FILES

site/
Fresh published output from the most recent target. Do not store maintained articles or run make in this directory.
site/index.html
Generated entry point. It contains every generated article in single-page mode or the first listed article in multi-page mode.
site/<slug>.html
Multi-page output for each listed article and each enabled unlisted article.
site/favicon.svg
Generated fallback or unchanged copy of SITE_ICON_PATH. Its link contains a cache version token.
site/feed.atom
Site-wide Atom feed generated when SITE_FEED=true.
site/theme-auto.css
Generated when DEFAULT_THEME_MODE is not off and a reachable theme has an automatic mapping. It contains light/dark rules derived from styles.css and the mappings in tools/theme-menu.html. Its link contains the shared stylesheet cache token.
site/styles.css
Copied site stylesheet.
THIRD_PARTY_NOTICES.txt
Maintained notice for bundled colour palettes. Targets copy it into site/ and release archives.
licenses/
Retained third-party licence notices. Targets copy the directory into site/ and release archives.
articles/<slug>/*
Maintained article source and published raw, download, and image assets. Targets copy them to site/articles/<slug>/.
arachnopress_GENERATOR_VERSION.tar.gz
Maintainer release archive produced by make release and consumed by make full from the same project root. Its top-level directory has the same name without the .tar.gz suffix.
README.arachnopress
The compact operator and interface reference.

EXIT STATUS

A target exits zero after its complete staged tree is published as site/, after release packaging when applicable, and after managed source cleanup. It exits non-zero on invalid settings or article structure, missing required tool inputs, duplicate or invalid IDs, no articles, or an unhandled command or filesystem failure.

Optional highlighting and checksum failures are not fatal. The build either emits escaped raw source or omits the checksum.

DIAGNOSTICS

Missing release archive
Run make release with the same generator version before make full. Run both commands from the same project root, not from site/.
DEFAULT_THEME does not exist in theme menu / Automatic theme mapping must name a theme option
Use theme radio IDs from tools/theme-menu.html. Define data-auto-dark and data-auto-light together, and include the source theme in its pair.
No listed articles found
Add or select at least one article not marked data-listed="false".
Extensionless article directory names must not contain dots / Extensionless article URL conflicts with a site root path
Rename the article directory and matching article ID. The slug must not contain a dot or match another generated root path.
Unsafe SITE_ICON_PATH / SITE_ICON_PATH_THEME requires SVG class
Use a readable relative .svg file in the staged tree. A themed icon must contain colour-out and colour-in class tokens.
Unsafe SITE_BACKGROUND_PATH / SITE_BACKGROUND requires SVG class
Enable the backdrop with a readable relative .svg file in the staged tree containing colour-out and colour-in class tokens.
Article, heading, or generated-marker format error
Apply the ARTICLE FORMAT and GENERATED BLOCKS rules. Keep indexed headings and empty generated markers on one source line.

Missing or invalid generated block files appear as visible missing blocks. They do not produce these fatal diagnostics.

SERVING

Deploy site/ as static files without changing its relative paths. It may instead be opened as site/index.html through file://; multi-page file access requires SITE_URL_STYLE=html.

Contact submission is for HTTP or HTTPS deployment. Serve contact forms over HTTPS and retain query strings in the access log to receive their contents.

OpenBSD httpd example

The OpenBSD httpd(8) example serves .atom as application/atom+xml and rewrites extensionless article paths. Replace its host and document-root paths, validate the configuration, and reload httpd.

Its final rule appends .html internally to a missing final path component without a dot. Keep specific rules before it.

/etc/httpd.confReplace host, certificate, and document-root pathsrawโ†“
types {
	include "/usr/share/misc/mime.types"
	application/atom+xml atom
}

server "arachnogoat.com" {
	listen on * port 80

	location "/.well-known/acme-challenge/*" {
		root "/acme"
		request strip 2
	}

	location * {
		block return 301 "https://$HTTP_HOST$REQUEST_URI"
	}
}

server "arachnogoat.com" {
	listen on * tls port 443

	tls {
		certificate "/etc/ssl/arachnogoat.com.fullchain.pem"
		key "/etc/ssl/private/arachnogoat.com.key"
	}

	root "/htdocs/arachnogoat.com"

	# Required when arachnopress uses SITE_URL_STYLE=extensionless.
	location not found match "/[^./]+$" {
		request rewrite "$DOCUMENT_URI.html"
	}
}

CAVEATS

Trusted input

Article HTML, a SITE_ICON_PATH SVG embedded by SITE_ICON_PATH_THEME, and a SITE_BACKGROUND_PATH SVG are trusted author content. The build escapes generated text and code, but does not sanitize author HTML or embedded SVG markup. Review them before building. The zero-JS guarantee applies to the generated interface; author HTML may add its own active content or external dependencies.

Single-page scale

Single-page output contains every generated article and highlighted code span. Large code-heavy collections increase HTML size and DOM cost. Use multi-page mode, focused excerpts, raw downloads, or HIGHLIGHTER=none when that cost becomes material.

Browser compatibility

Older clients may not support every CSS or popover feature. See REQUIREMENTS.

LICENSE

arachnopress is distributed under the BSD 3-Clause license. See tools/license.txt or the License control in the site metadata. Bundled colour palettes retain their upstream values and third-party licences; see THIRD_PARTY_NOTICES.txt and licenses/.

Patch Notes

bsddd.rd

OpenBSD/amd64 parallel disk-overwrite ramdisk and miniroot targets.

Downloadbsddd.rd.patchApplies below /usr/src27 KiBSHA256 (bsddd.rd.patch) = baefc54163b41f43799a6379ee7606a9bc6bb7affe66304f91c071175b24480d

DESCRIPTION

The patch adds separate bsddd.rd and minirootXX_dd.img targets. The ramdisk waits for one eligible sd(4) or wd(4) disk to disappear, then overwrites the remaining disks concurrently. Two console confirmations or an optional timeout instead select every eligible attached disk.

/dev/zero is the default input. /dev/urandom is selectable at build time. Console messages and interval-based bells report status.

The patch adds files only. Stock source files, Makefiles, bsd.rd, minirootXX.img, and cdXX.iso targets are unchanged. Enhanced targets are available only through Makefile.bsddd.

INTENDED USE

bsddd.rd supports batch whole-disk overwriting when preparing decommissioned systems for disposal or reuse, particularly where device-native sanitize or secure-erase facilities are unavailable or impractical. It runs from RAM, allowing boot media and optional console equipment to be moved between machines while wipes continue.

PATCH

The aggregate patch contains the complete build overlay and README.

bsddd.rd.patchrawโ†“
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/bsddd/Makefile.bsddd
@@ -0,0 +1,233 @@
+#	$OpenBSD$
+
+.include <bsd.own.mk>
+
+# Stock ramdisk_cd outer-image size, retained as an override point.
+BSDDD_FSSIZE?=	11392
+BSDDD_DD_IF?=	/dev/zero
+BSDDD_WIPE_TIMEOUT?=	0
+
+BSDDD_BASEDIR=	${.CURDIR}/..
+BSDDD_TOP=	${.CURDIR}/../../../..
+BSDDD_UTILS=	${BSDDD_BASEDIR}/../../miniroot
+BSDDD_MTREE=	${BSDDD_UTILS}/mtree.conf
+BSDDD_EFIBOOT=	${DESTDIR}/usr/mdec/BOOTX64.EFI \
+		${DESTDIR}/usr/mdec/BOOTIA32.EFI
+BSDDD_MOUNT_ARGS_MSDOS=	-o-s
+
+BSDDD_RAMDISK=	RAMDISK_CD_DD
+BSDDD_KERNEL=	${.OBJDIR}/bsd
+BSDDD_FS=	miniroot${OSrev}_dd.img
+BSDDD_BUILDOBJDIR=	${.OBJDIR}/build
+BSDDD_KERNELOBJDIR=	${BSDDD_BUILDOBJDIR}/kernel/${BSDDD_RAMDISK}
+BSDDD_INSTBIN=	${BSDDD_BUILDOBJDIR}/instbin
+BSDDD_RDOBJDIR=	${.OBJDIR}/rdobj
+BSDDD_MOUNT_POINT=	${.OBJDIR}/mnt
+BSDDD_VND=	${.OBJDIR}/vnd
+BSDDD_BOOT=	${.OBJDIR}/boot
+BSDDD_OBJCHECK=	${.OBJDIR}/.bsddd-obj-ok
+BSDDD_MAKEFILE=	${.CURDIR}/Makefile.bsddd
+BSDDD_KERNELCONF=	${.CURDIR}/${BSDDD_RAMDISK}
+BSDDD_BASECONF=	${BSDDD_TOP}/sys/arch/${MACHINE}/conf/RAMDISK_CD
+BSDDD_KERNELCONFDEPS=	${BSDDD_BASECONF}
+BSDDD_LISTS=	${BSDDD_BASEDIR}/list ${.CURDIR}/list.dd
+BSDDD_SCRIPTSRC=	${.CURDIR}/bsddd
+BSDDD_SCRIPT=	${BSDDD_RDOBJDIR}/bsddd
+BSDDD_PROFILE=	${.CURDIR}/dot.profile
+# Stock 7360-block rdroot; keep paired with RAMDISK_CD_DD MINIROOTSIZE.
+BSDDD_RDROOT_SIZE?=	3680k
+BSDDD_MRMAKEFSARGS?=	-s ${BSDDD_RDROOT_SIZE} \
+		-o rdroot,minfree=0,bsize=4096,fsize=512,density=4096
+
+.PHONY: bsddd
+bsddd: bsddd.rd ${BSDDD_FS}
+
+${BSDDD_OBJCHECK}:
+	@if [ "${.OBJDIR}" = "${.CURDIR}" ]; then \
+		echo "private object directory is not active;" >&2; \
+		echo "run 'make -f Makefile.bsddd obj' separately first" >&2; \
+		exit 1; \
+	fi
+	touch $@
+
+${BSDDD_KERNEL}: ${BSDDD_OBJCHECK} ${BSDDD_MAKEFILE} \
+	    ${BSDDD_KERNELCONF} ${BSDDD_KERNELCONFDEPS}
+	install -d -o ${BUILDUSER} -g ${WOBJGROUP} ${BSDDD_KERNELOBJDIR}
+	su ${BUILDUSER} -c \
+	    'config -b ${BSDDD_KERNELOBJDIR} -s ${BSDDD_TOP}/sys \
+	    ${BSDDD_KERNELCONF} && cd ${BSDDD_KERNELOBJDIR} && \
+	    MAKEOBJDIR=${BSDDD_KERNELOBJDIR} ${MAKE} clean && \
+	    exec env MAKEOBJDIR=${BSDDD_KERNELOBJDIR} ${MAKE} ${MFLAGS}'
+	cp -p ${BSDDD_KERNELOBJDIR}/bsd $@
+
+bsddd.gz: bsddd.rd
+	objcopy -g -x -R .comment -R .SUNW_ctf \
+	    -K rd_root_size -K rd_root_image \
+	    bsddd.rd bsddd.strip
+	gzip -9cn bsddd.strip > bsddd.gz
+
+.PHONY: bsddd-stock-deps bsddd-instbin
+bsddd-stock-deps: ${BSDDD_OBJCHECK} ${BSDDD_MAKEFILE}
+	@_objroot=`cd ${BSDDD_BASEDIR} && ${MAKE} -V BSDOBJDIR`; \
+	if [ ! -d "$$_objroot" ]; then \
+		echo "normal OpenBSD object root does not exist: $$_objroot" >&2; \
+		echo "create it before building bsddd" >&2; \
+		exit 1; \
+	fi
+	cd ${BSDDD_TOP}/lib && ${MAKE} obj
+	cd ${BSDDD_TOP}/distrib/special && ${MAKE} obj
+	cd ${BSDDD_BASEDIR} && ${MAKE} obj
+	@_srcdir=`cd ${BSDDD_BASEDIR} && pwd`; \
+	_objdir=`cd ${BSDDD_BASEDIR} && ${MAKE} -V .OBJDIR`; \
+	if [ "$$_objdir" = "$$_srcdir" ] || [ ! -d "$$_objdir" ]; then \
+		echo "normal ramdisk_cd object directory is not active" >&2; \
+		exit 1; \
+	fi
+	cd ${BSDDD_TOP}/distrib/special/libstubs && ${MAKE} ${MFLAGS}
+
+bsddd-instbin: bsddd-stock-deps
+	cd ${BSDDD_BASEDIR} && ${MAKE} ${MFLAGS} instbin
+	install -d ${BSDDD_BUILDOBJDIR}
+	@_objdir=`cd ${BSDDD_BASEDIR} && ${MAKE} -V .OBJDIR`; \
+	if [ ! -f "$$_objdir/instbin" ]; then \
+		echo "stock ramdisk_cd instbin was not built" >&2; \
+		exit 1; \
+	fi; \
+	cp -p "$$_objdir/instbin" ${BSDDD_INSTBIN}
+
+bsddd.rd: bsddd-files bsddd-instbin ${BSDDD_KERNEL}
+	install -d ${BSDDD_RDOBJDIR}
+	rm -f ${BSDDD_RDOBJDIR}/instbin \
+	    ${BSDDD_RDOBJDIR}/mr.fs \
+	    ${BSDDD_RDOBJDIR}/bsd.rd
+	cp -p ${BSDDD_INSTBIN} ${BSDDD_RDOBJDIR}/instbin
+	rm -rf ${BSDDD_RDOBJDIR}/mr.fs.d
+	install -d -o root -g wheel ${BSDDD_RDOBJDIR}/mr.fs.d
+	mtree -def ${BSDDD_MTREE} -p ${BSDDD_RDOBJDIR}/mr.fs.d -u
+	CURDIR=${BSDDD_BASEDIR} OBJDIR=${BSDDD_RDOBJDIR} OSrev=${OSrev} \
+	    TARGDIR=${BSDDD_RDOBJDIR}/mr.fs.d UTILS=${BSDDD_UTILS} \
+	    RELEASEDIR=${RELEASEDIR} sh ${BSDDD_UTILS}/runlist.sh \
+	    ${BSDDD_LISTS}
+	rm ${BSDDD_RDOBJDIR}/mr.fs.d/instbin
+	makefs ${BSDDD_MRMAKEFSARGS} ${BSDDD_RDOBJDIR}/mr.fs \
+	    ${BSDDD_RDOBJDIR}/mr.fs.d
+	cp -p ${BSDDD_KERNEL} ${BSDDD_RDOBJDIR}/bsd.rd
+	rdsetroot ${BSDDD_RDOBJDIR}/bsd.rd ${BSDDD_RDOBJDIR}/mr.fs
+	cp ${BSDDD_RDOBJDIR}/bsd.rd $@
+
+${BSDDD_FS}: bsddd.gz
+	-umount -f ${BSDDD_MOUNT_POINT} >/dev/null 2>&1
+	@if [ -e ${BSDDD_VND} ] && [ ! -s ${BSDDD_VND} ]; then \
+		rm -f ${BSDDD_VND}; \
+	fi
+	@if [ -e ${BSDDD_VND} ]; then \
+		echo "stale private vnd state; run" \
+		    "'make -f Makefile.bsddd unconfig-bsddd' first" >&2; \
+		exit 1; \
+	fi
+	install -d ${BSDDD_MOUNT_POINT}
+	dd if=/dev/zero of=${BSDDD_FS} bs=512 count=${BSDDD_FSSIZE}
+	vnconfig -v ${.OBJDIR}/${BSDDD_FS} > ${BSDDD_VND}
+	fdisk -yi -l ${BSDDD_FSSIZE} -b 960 -f ${DESTDIR}/usr/mdec/mbr \
+	    `cat ${BSDDD_VND}`
+	echo '/ *' | disklabel -wAT- `cat ${BSDDD_VND}`
+	newfs -t msdos /dev/r`cat ${BSDDD_VND}`i
+	mount ${BSDDD_MOUNT_ARGS_MSDOS} /dev/`cat ${BSDDD_VND}`i \
+	    ${BSDDD_MOUNT_POINT}
+	mkdir -p ${BSDDD_MOUNT_POINT}/efi/boot
+	cp ${BSDDD_EFIBOOT} ${BSDDD_MOUNT_POINT}/efi/boot
+	umount ${BSDDD_MOUNT_POINT}
+	newfs -O 1 -m 0 -o space -i 524288 -c ${BSDDD_FSSIZE} \
+	    /dev/r`cat ${BSDDD_VND}`a
+	mount /dev/`cat ${BSDDD_VND}`a ${BSDDD_MOUNT_POINT}
+	objcopy -S -R .comment ${DESTDIR}/usr/mdec/boot ${BSDDD_BOOT}
+	installboot -v -r ${BSDDD_MOUNT_POINT} `cat ${BSDDD_VND}` \
+	    ${DESTDIR}/usr/mdec/biosboot ${BSDDD_BOOT}
+	install -c -m 555 -o root -g wheel bsddd.gz \
+	    ${BSDDD_MOUNT_POINT}/bsd
+	df -i ${BSDDD_MOUNT_POINT}
+	umount ${BSDDD_MOUNT_POINT}
+	vnconfig -u `cat ${BSDDD_VND}`
+	rm -f ${BSDDD_VND}
+
+.PHONY: bsddd-check bsddd-files
+bsddd-check:
+	@case "${BSDDD_DD_IF}" in \
+	/dev/zero|/dev/urandom) ;; \
+	*) echo "BSDDD_DD_IF must be /dev/zero or /dev/urandom" >&2; exit 1;; \
+	esac
+	@case "${BSDDD_WIPE_TIMEOUT}" in \
+	''|*[!0-9]*|0[0-9]*) \
+		echo "BSDDD_WIPE_TIMEOUT must be 0 or a positive decimal integer" >&2; \
+		exit 1;; \
+	esac
+	@if ! [ "${BSDDD_WIPE_TIMEOUT}" -le 2147483647 ]; then \
+		echo "BSDDD_WIPE_TIMEOUT exceeds 2147483647" >&2; \
+		exit 1; \
+	fi
+	test -s ${BSDDD_SCRIPTSRC}
+	test -s ${BSDDD_PROFILE}
+	test -s ${.CURDIR}/list.dd
+
+bsddd-files: ${BSDDD_OBJCHECK} bsddd-check ${BSDDD_SCRIPTSRC} \
+	    ${BSDDD_PROFILE} ${.CURDIR}/list.dd
+	install -d ${BSDDD_RDOBJDIR}
+	rm -f ${BSDDD_SCRIPT}.tmp
+	sed -e 's|^DD_IF=.*|DD_IF=${BSDDD_DD_IF}|' \
+	    -e 's|^WIPE_TIMEOUT=.*|WIPE_TIMEOUT=${BSDDD_WIPE_TIMEOUT}|' \
+	    ${BSDDD_SCRIPTSRC} > \
+	    ${BSDDD_SCRIPT}.tmp
+	grep -Fqx 'DD_IF=${BSDDD_DD_IF}' ${BSDDD_SCRIPT}.tmp
+	grep -Fqx 'WIPE_TIMEOUT=${BSDDD_WIPE_TIMEOUT}' ${BSDDD_SCRIPT}.tmp
+	mv ${BSDDD_SCRIPT}.tmp ${BSDDD_SCRIPT}
+
+.PHONY: unconfig-bsddd
+unconfig-bsddd:
+	-umount -f ${BSDDD_MOUNT_POINT} >/dev/null 2>&1
+	@if [ -e ${BSDDD_VND} ] && [ ! -s ${BSDDD_VND} ]; then \
+		rm -f ${BSDDD_VND}; \
+	elif [ -f ${BSDDD_VND} ]; then \
+		_vnd=`cat ${BSDDD_VND}`; \
+		_unit=$${_vnd#vnd}; \
+		if [ "vnd$$_unit" != "$$_vnd" ] || [ -z "$$_unit" ]; then \
+			echo "invalid private vnd state: $$_vnd" >&2; exit 1; \
+		fi; \
+		case "$$_unit" in \
+		*[!0-9]*) echo "invalid private vnd state: $$_vnd" >&2; exit 1;; \
+		esac; \
+		_info=`vnconfig -l "$$_vnd"` || exit 1; \
+		case "$$_info" in \
+		"$$_vnd: not in use") rm -f ${BSDDD_VND} ;; \
+		"$$_vnd: covering ${.OBJDIR}/${BSDDD_FS} on "*) \
+			vnconfig -u "$$_vnd" && rm -f ${BSDDD_VND} ;; \
+		*) echo "refusing to detach vnd not owned by bsddd: $$_info" >&2; \
+			exit 1 ;; \
+		esac; \
+	fi
+
+.ifdef RELEASEDIR
+.PHONY: install-bsddd
+install-bsddd: bsddd.gz ${BSDDD_FS}
+	cp bsddd.gz ${RELEASEDIR}/bsddd.rd
+	cp ${BSDDD_FS} ${RELEASEDIR}
+	chmod a+r ${RELEASEDIR}/bsddd.rd ${RELEASEDIR}/${BSDDD_FS}
+.endif
+
+.PHONY: clean-bsddd clean cleandir
+clean-bsddd: unconfig-bsddd
+	rm -f bsddd.rd bsddd.gz bsddd.strip ${BSDDD_FS} \
+	    ${BSDDD_KERNEL} ${BSDDD_BOOT} ${BSDDD_OBJCHECK}
+	rm -rf ${BSDDD_BUILDOBJDIR} ${BSDDD_RDOBJDIR}
+	-rmdir ${BSDDD_MOUNT_POINT}
+
+clean cleandir: clean-bsddd
+
+.PHONY: prepare-unpatch-bsddd
+prepare-unpatch-bsddd: clean-bsddd
+	@if [ -L ${.CURDIR}/obj ]; then \
+		_obj=`readlink ${.CURDIR}/obj`; \
+		echo "empty private object directory may be removed: $$_obj"; \
+		rm -f ${.CURDIR}/obj; \
+	fi
+
+.include <bsd.obj.mk>
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/bsddd/RAMDISK_CD_DD
@@ -0,0 +1,12 @@
+#	$OpenBSD$
+
+include "arch/amd64/conf/RAMDISK_CD"
+
+# Retain an explicit, stock-sized rdroot override for later adjustment.
+option		BSDDD_RDROOT
+rmoption	MINIROOTSIZE
+option		MINIROOTSIZE=7360
+rmoption	BSDDD_RDROOT
+
+pcppi0	at isa?
+spkr0	at pcppi?
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/bsddd/README.bsddd
@@ -0,0 +1,238 @@
+BSDDD.RD                    bsddd.rd build notes                    BSDDD.RD
+
+NAME
+     bsddd.rd - OpenBSD/amd64 parallel disk-overwrite ramdisk
+
+DESCRIPTION
+     The patch adds separate bsddd.rd and minirootXX_dd.img targets.  The
+     ramdisk waits for one eligible sd(4) or wd(4) disk to disappear, then
+     overwrites the remaining disks concurrently.  Two console confirmations
+     or an optional timeout instead select every eligible attached disk.
+
+     /dev/zero is the default input.  /dev/urandom is selectable at build
+     time.  Console messages and interval-based bells report status.
+
+     The patch adds files only.  Stock source files, Makefiles, bsd.rd,
+     minirootXX.img, and cdXX.iso targets are unchanged.  The enhanced targets
+     are available only through Makefile.bsddd.
+
+INTENDED USE
+     bsddd.rd supports batch whole-disk overwriting when preparing
+     decommissioned systems for disposal or reuse, particularly where
+     device-native sanitize or secure-erase facilities are unavailable or
+     impractical.  It runs from RAM, allowing boot media and optional console
+     equipment to be moved between machines while wipes continue.
+
+SYNOPSIS
+     Build on the matching OpenBSD/amd64 release after completing the normal
+     source and release prerequisites.  BSDOBJDIR and the amd64 boot files
+     below ${DESTDIR}/usr/mdec must exist.  The documented commands require
+     root privileges.
+
+     Apply the patch below /usr/src, create the private object link, then build:
+
+           cd /usr/src
+           patch -p1 < /root/bsddd.rd.patch
+           cd distrib/amd64/ramdisk_cd/bsddd
+           make -f Makefile.bsddd obj
+           make -f Makefile.bsddd
+
+     Select pseudorandom input instead:
+
+           make -f Makefile.bsddd BSDDD_DD_IF=/dev/urandom
+
+     Enable a 300-second all-disk timeout:
+
+           make -f Makefile.bsddd BSDDD_WIPE_TIMEOUT=300
+
+     Install release artifacts when RELEASEDIR is set:
+
+           make -f Makefile.bsddd install-bsddd \
+               RELEASEDIR=/path/to/release
+
+TARGETS
+     bsddd
+             Builds bsddd.rd and minirootXX_dd.img.
+
+     install-bsddd
+             Defined when RELEASEDIR is set.  Installs compressed bsddd.rd and
+             minirootXX_dd.img.
+
+     unconfig-bsddd
+             Recovers private mount and vnd(4) state after an interrupted
+             media build.  An unowned vnd(4) is not detached.
+
+     clean-bsddd, clean, cleandir
+             Remove private outputs.  Stock instbin objects remain under
+             normal OpenBSD clean ownership.
+
+     prepare-unpatch-bsddd
+             Cleans private outputs, prints the private object path, and
+             removes the source obj symlink.
+
+BUILD MODEL
+     make obj creates a private object link below BSDOBJDIR.  The custom
+     kernel, rdroot, instbin copy, and media objects remain below that path.
+
+     The wrapper builds stock ramdisk_cd instbin objects in their normal object
+     directories, then copies instbin into private state.  Stock source,
+     configuration, installed use, and cleanup ownership are unchanged.  Do
+     not run bsddd concurrently with a build or clean using those objects.
+
+     The private rdroot replaces .profile, installs /bsddd, and creates
+     /dev/speaker.  It adds no userland binaries.  The private kernel retains
+     the stock rdroot size and adds pcppi(4) and speaker(4).
+
+     No parent Makefile or SUBDIR list is changed.  Normal builds and cleans do
+     not enter the bsddd source directory or its private objects.
+
+DISK SELECTION
+     /bsddd reads hw.disknames with sysctl(8) and selects names matching
+     sd[0-9]* and wd[0-9]*.  This includes softraid(4) volumes, their backing
+     disks, and disks presented through common amd64 storage controllers.
+
+     Wiping begins when the current set equals the initial set minus exactly
+     one name.  Other changes leave the ramdisk waiting.  The accepted set is
+     frozen; later arrivals are excluded.  The interlock cannot identify the
+     boot medium.  Two explicit y responses or timeout expiry select every
+     eligible attached disk.  The timer continues during both prompts.
+
+     Other device classes are excluded.  After the interlock, /bsddd detaches
+     auto-assembled softraid(4) volumes and removes their names from the set.
+     Their backing disks remain selected.  Inspection or detach failure stops
+     before writes.
+
+     MAKEDEV(8) creates device nodes.  disklabel(8) supplies sector size and
+     total sectors.  dd(1) writes each whole-disk raw character device in 1 MiB
+     transfers followed by an exact sector-sized tail.  Disks run concurrently.
+     One device failure does not stop other jobs.  A status failure waits for
+     active jobs.  sync(8) precedes the final result.
+
+FEEDBACK
+     Console-bell counts and intervals identify each state:
+
+           three short bells        waiting without a timeout
+           four rapid bells         waiting with an all-disk timeout
+           two separated bells      disk writes started
+           one bell                 wipe progress
+           five short bells         all selected disks completed
+           three separated bells    setup, status, or disk-write failure
+
+     Ready patterns repeat every 10 seconds.  Completion and failure patterns
+     repeat every 30 seconds.  Progress is reported every 60 seconds.  A
+     console bell is used if speaker output fails.  The result loop continues
+     until power-off or reset.
+
+USE
+     Write minirootXX_dd.img to removable media.  This replaces the selected
+     device's partition table and filesystems.  Verify the output device.  For
+     example, as root on OpenBSD:
+
+           dd if=./miniroot79_dd.img of=/dev/rsdXc bs=1m
+           sync
+
+     Boot the target.  At the ready pattern, remove the boot medium.  Removal
+     of any eligible disk satisfies the name-based interlock.  Two separated
+     bells mark write startup.  Wait for the repeating completion pattern
+     before power-off.  Stock amd64 BIOS and UEFI boot programs are used.
+     Secure Boot is unsupported.
+
+     A boot loader or matching OpenBSD/amd64 pxeboot(8) may load the compressed
+     bsddd.rd directly.  Without removable media, use the console confirmations.
+
+REBUILD AND CLEAN
+     Rebuild private outputs:
+
+           cd /usr/src/distrib/amd64/ramdisk_cd/bsddd
+           make -f Makefile.bsddd clean
+           make -f Makefile.bsddd
+
+     Recover an interrupted media build first:
+
+           make -f Makefile.bsddd unconfig-bsddd
+
+     Before reversing the patch:
+
+           make -f Makefile.bsddd prepare-unpatch-bsddd
+           cd /usr/src
+           patch -R -p1 < /root/bsddd.rd.patch
+           rmdir distrib/amd64/ramdisk_cd/bsddd
+
+VARIABLES
+     BSDDD_DD_IF
+             Build-time dd(1) input device.  /dev/zero is the default.
+             /dev/urandom supplies non-blocking pseudorandom data but may limit
+             throughput.  No other value is accepted.
+
+     BSDDD_WIPE_TIMEOUT
+             Build-time automatic all-disk deadline in seconds.  The default,
+             0, disables it.  Values from 1 through 2147483647 enable it.
+             Expiry selects every eligible attached disk, including boot media.
+
+     BSDDD_FSSIZE
+             Outer miniroot size in 512-byte blocks.  Default: 11392, matching
+             stock ramdisk_cd.
+
+     BSDDD_RDROOT_SIZE
+             Private rdroot size.  Default: 3680k, matching 7360 512-byte
+             blocks.  An override must match MINIROOTSIZE in RAMDISK_CD_DD.
+
+     BSDDD_MRMAKEFSARGS
+             makefs(8) arguments for the private rdroot.  The default uses
+             BSDDD_RDROOT_SIZE and stock filesystem parameters.
+
+     BSDOBJDIR
+             Normal OpenBSD object root and base for the private obj symlink.
+             Default: /usr/obj.
+
+     DESTDIR
+             Optional prefix for installed amd64 boot files under usr/mdec.
+             Default: empty.
+
+     RELEASEDIR
+             Enables install-bsddd and names its destination directory.
+
+FILES
+     distrib/amd64/ramdisk_cd/bsddd/Makefile.bsddd
+             Private build wrapper.
+
+     distrib/amd64/ramdisk_cd/bsddd/RAMDISK_CD_DD
+             Private amd64 kernel configuration.
+
+     distrib/amd64/ramdisk_cd/bsddd/list.dd
+             Ramdisk overlay list.
+
+     distrib/amd64/ramdisk_cd/bsddd/dot.profile
+             Single-user profile that replaces itself with /bsddd.
+
+     distrib/amd64/ramdisk_cd/bsddd/bsddd
+             Disk enumeration, parallel overwrite, and status script.
+
+     obj/bsddd.rd
+             Uncompressed ramdisk kernel.
+
+     obj/bsddd.gz
+             Stripped and compressed kernel installed as bsddd.rd.
+
+     obj/minirootXX_dd.img
+             Bootable BIOS/UEFI whole-device image.
+
+CAVEATS
+     This operation is irreversible.  Console confirmation and timeout expiry
+     select every eligible attached disk, including boot media.
+
+     A single logical overwrite pass is not a hardware secure erase.  Remapped
+     sectors, flash translation layers, controller caches, hidden regions, and
+     inaccessible or unsupported devices may retain data.  Use device-native
+     sanitize, secure-erase, or cryptographic-erasure procedures when the
+     required disposal policy covers those areas.
+
+     Audible feedback requires a working PC speaker or console bell.  Verify
+     feedback and storage-controller support before unattended use.
+
+SEE ALSO
+     bioctl(8), dd(1), disklabel(8), make(1), MAKEDEV(8), makefs(8), pcppi(4),
+     pxeboot(8), random(4), rdsetroot(8), speaker(4), sync(8), sysctl(8),
+     vnconfig(8)
+
+BSDDD.RD                       August 18, 2026                       BSDDD.RD
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/bsddd/bsddd
@@ -0,0 +1,365 @@
+#!/bin/ksh
+
+set +o sh
+export PATH=/sbin:/bin:/usr/bin:/usr/sbin:/
+
+POLL_INTERVAL=2
+READY_INTERVAL=10
+PROGRESS_INTERVAL=60
+RESULT_INTERVAL=30
+DD_IF=/dev/zero
+WIPE_TIMEOUT=0
+
+trap '' HUP INT QUIT TSTP
+
+beep() {
+	if [[ -c /dev/speaker ]] &&
+	    print -nr -- 'T240 O4 L8 C' >/dev/speaker 2>/dev/null; then
+		return
+	fi
+	print -n '\a'
+}
+
+beep_pattern() {
+	typeset _count=$1 _interval=$2
+
+	while ((_count > 0)); do
+		beep
+		_count=$((_count - 1))
+		((_count > 0)) && sleep "$_interval"
+	done
+}
+
+result_loop() {
+	typeset _message=$1 _count=$2 _interval=$3
+
+	while :; do
+		echo
+		echo "$_message"
+		beep_pattern "$_count" "$_interval"
+		sleep $RESULT_INTERVAL
+	done
+}
+
+get_disks() {
+	typeset _entry _disk _disks='' _oldifs=$IFS
+
+	IFS=,
+	for _entry in $(sysctl -n hw.disknames); do
+		_disk=${_entry%%:*}
+		case $_disk in
+		sd+([0-9])|wd+([0-9]))
+			_disks="${_disks}${_disks:+ }$_disk"
+			;;
+		esac
+	done
+	IFS=$_oldifs
+	print -r -- "$_disks"
+}
+
+compare_disks() {
+	typeset _disk
+
+	_removed=''
+	_removed_count=0
+	_added_count=0
+	for _disk in $_initial_disks; do
+		case " $_current_disks " in
+		*" $_disk "*) ;;
+		*)
+			_removed="${_removed}${_removed:+ }$_disk"
+			_removed_count=$((_removed_count + 1))
+			;;
+		esac
+	done
+	for _disk in $_current_disks; do
+		case " $_initial_disks " in
+		*" $_disk "*) ;;
+		*)
+			_added_count=$((_added_count + 1))
+			;;
+		esac
+	done
+}
+
+start_reader() {
+	rm -f /tmp/bsddd.answer
+	(
+		typeset _answer
+		IFS= read -r _answer </dev/console || _answer=
+		if [[ $_answer == [yY] ]]; then
+			print -n 'Wipe every attached sd/wd disk, including boot media? [y/N] ' \
+			    >/dev/console
+			IFS= read -r _answer </dev/console || _answer=
+		fi
+		print -r -- "$_answer" > /tmp/bsddd.answer
+	) &
+	_reader=$!
+}
+
+stop_reader() {
+	kill "$_reader" 2>/dev/null || :
+	wait "$_reader" 2>/dev/null || :
+	rm -f /tmp/bsddd.answer
+}
+
+wipe_disk() {
+	typeset _disk=$1 _geometry _sector_size _sectors
+	typeset _sectors_per_mib _full _tail _raw
+
+	(cd /dev && sh MAKEDEV "$_disk") || return 1
+	_raw=/dev/r${_disk}c
+	[[ -c $_raw ]] || return 1
+
+	_geometry=$(disklabel -d "$_disk" 2>/dev/null) || return 1
+	_sector_size=$(print -r -- "$_geometry" |
+	    sed -n 's/^bytes\/sector:[[:space:]]*//p')
+	_sectors=$(print -r -- "$_geometry" |
+	    sed -n 's/^total sectors:[[:space:]]*\([0-9][0-9]*\).*/\1/p')
+
+	case $_sector_size in
+	''|*[!0-9]*) return 1 ;;
+	esac
+	case $_sectors in
+	''|*[!0-9]*) return 1 ;;
+	esac
+	((_sector_size > 0 && _sectors > 0)) || return 1
+
+	echo "$_disk: overwriting $_sectors sectors at $_sector_size bytes"
+
+	if ((1048576 % _sector_size != 0)); then
+		dd if="$DD_IF" of="$_raw" bs="$_sector_size" count="$_sectors" \
+		    status=none || return 1
+	else
+		_sectors_per_mib=$((1048576 / _sector_size))
+		_full=$((_sectors / _sectors_per_mib))
+		_tail=$((_sectors % _sectors_per_mib))
+
+		if ((_full > 0)); then
+			dd if="$DD_IF" of="$_raw" bs=1m count="$_full" \
+			    status=none || return 1
+		fi
+		if ((_tail > 0)); then
+			dd if="$DD_IF" of="$_raw" bs="$_sector_size" \
+			    seek="$((_full * _sectors_per_mib))" count="$_tail" \
+			    status=none || return 1
+		fi
+	fi
+
+	echo "$_disk: complete"
+}
+
+_initial_disks=$(get_disks)
+if [[ -z $_initial_disks ]]; then
+	result_loop 'ERROR: no eligible sd(4) or wd(4) disks were found.' 3 1
+fi
+
+echo
+echo "bsddd.rd is resident in memory."
+echo "Detected disks: $_initial_disks"
+echo "Remove exactly one disk to begin automatically."
+_timeout_deadline=0
+if ((WIPE_TIMEOUT > 0)); then
+	echo "Automatic all-disk wipe in $WIPE_TIMEOUT seconds."
+	_timeout_deadline=$((SECONDS + WIPE_TIMEOUT))
+fi
+echo "Proceed without detected device removal? [y/N]"
+
+start_reader
+_ready_elapsed=$READY_INTERVAL
+_last_disks=$_initial_disks
+_override=no
+
+while :; do
+	_current_disks=$(get_disks)
+	compare_disks
+	if ((_removed_count == 1 && _added_count == 0)); then
+		stop_reader
+		_disks=$_current_disks
+		echo
+		echo "Removed disk: $_removed"
+		break
+	fi
+
+	if [[ $_current_disks != "$_last_disks" ]]; then
+		echo "Waiting; current disks: ${_current_disks:-none}"
+		_last_disks=$_current_disks
+	fi
+
+	if [[ -s /tmp/bsddd.answer ]]; then
+		_answer=$(</tmp/bsddd.answer)
+		wait "$_reader" 2>/dev/null || :
+		rm -f /tmp/bsddd.answer
+		if [[ $_answer == [yY] ]]; then
+			_override=console
+			_disks=$(get_disks)
+			break
+		fi
+		echo "Proceed without detected device removal? [y/N]"
+		start_reader
+	elif ! kill -0 "$_reader" 2>/dev/null; then
+		wait "$_reader" 2>/dev/null || :
+		start_reader
+	fi
+
+	if ((_timeout_deadline > 0 && SECONDS >= _timeout_deadline)); then
+		stop_reader
+		_override=timeout
+		_disks=$(get_disks)
+		break
+	fi
+
+	if ((_ready_elapsed >= READY_INTERVAL)); then
+		if ((_timeout_deadline > 0)); then
+			beep_pattern 4 0.15
+		else
+			beep_pattern 3 0.25
+		fi
+		_ready_elapsed=0
+	fi
+	_sleep=$POLL_INTERVAL
+	if ((_timeout_deadline > 0)); then
+		_remaining=$((_timeout_deadline - SECONDS))
+		((_remaining < _sleep)) && _sleep=$_remaining
+		((_sleep > 0)) || continue
+	fi
+	sleep "$_sleep"
+	_ready_elapsed=$((_ready_elapsed + _sleep))
+done
+
+if ! _softraid_output=$(bioctl softraid0 2>/dev/null); then
+	result_loop 'ERROR: cannot inspect softraid volumes.' 3 1
+fi
+_softraid_volumes=$(print -r -- "$_softraid_output" |
+    sed -n 's/^softraid0.*\(sd[0-9][0-9]*\).*/\1/p')
+for _volume in $_softraid_volumes; do
+	echo "$_volume: detaching softraid volume"
+	if ! bioctl -d "$_volume" >/dev/null 2>&1; then
+		result_loop "ERROR: cannot detach softraid volume: $_volume" 3 1
+	fi
+done
+
+_wipe_disks=
+for _disk in $_disks; do
+	case " $_softraid_volumes " in
+	*" $_disk "*) ;;
+	*) _wipe_disks="${_wipe_disks}${_wipe_disks:+ }$_disk" ;;
+	esac
+done
+_disks=$_wipe_disks
+if [[ -z $_disks ]]; then
+	result_loop 'ERROR: no eligible sd(4) or wd(4) disks remain.' 3 1
+fi
+
+_status_dir=/tmp/bsddd.status
+if ! rm -rf "$_status_dir" || ! mkdir "$_status_dir"; then
+	result_loop 'ERROR: cannot initialize wipe status storage.' 3 1
+fi
+for _disk in $_disks; do
+	_status_file=$_status_dir/$_disk
+	_status_ok=${_status_file}.ok
+	_status_failed=${_status_file}.failed
+	if ! print -r -- running > "$_status_file" ||
+	    ! print -r -- ok > "$_status_ok" ||
+	    ! print -r -- failed > "$_status_failed" ||
+	    [[ $(<"$_status_file") != running ]] ||
+	    [[ $(<"$_status_ok") != ok ]] ||
+	    [[ $(<"$_status_failed") != failed ]]; then
+		result_loop 'ERROR: cannot initialize wipe status storage.' 3 1
+	fi
+done
+
+echo
+if [[ $_override == console ]]; then
+	echo "Console override confirmed."
+elif [[ $_override == timeout ]]; then
+	echo "Wipe timeout expired; all attached sd/wd disks selected."
+fi
+echo "Wiping disks in parallel: $_disks"
+echo "Wipe input: $DD_IF"
+echo "DO NOT POWER OFF."
+beep_pattern 2 1
+
+_jobs=
+_total=0
+for _disk in $_disks; do
+	(
+		_status_file=$_status_dir/$_disk
+		if wipe_disk "$_disk"; then
+			mv "${_status_file}.ok" "$_status_file" || exit 2
+			exit 0
+		else
+			mv "${_status_file}.failed" "$_status_file" || exit 2
+			exit 1
+		fi
+	) &
+	_jobs="${_jobs}${_jobs:+ }$!:$_disk"
+	_total=$((_total + 1))
+done
+
+_elapsed=0
+_status_error=no
+while :; do
+	_done=0
+	for _job in $_jobs; do
+		_pid=${_job%%:*}
+		_disk=${_job#*:}
+		_status_file=$_status_dir/$_disk
+		if [[ ! -r $_status_file ]]; then
+			_status_error=yes
+			break
+		fi
+		_status=$(<"$_status_file")
+		case $_status in
+		ok|failed)
+			_done=$((_done + 1))
+			;;
+		running)
+			if ! kill -0 "$_pid" 2>/dev/null; then
+				_status_error=yes
+				break
+			fi
+			;;
+		*)
+			_status_error=yes
+			break
+			;;
+		esac
+	done
+	[[ $_status_error == yes ]] && break
+	((_done == _total)) && break
+
+	sleep $POLL_INTERVAL
+	_elapsed=$((_elapsed + POLL_INTERVAL))
+	if ((_elapsed >= PROGRESS_INTERVAL)); then
+		echo "Wipe in progress: $_done/$_total disks complete."
+		beep_pattern 1 0
+		_elapsed=0
+	fi
+done
+
+if [[ $_status_error == yes ]]; then
+	echo "ERROR: wipe status unavailable; waiting for disk jobs."
+fi
+for _job in $_jobs; do
+	_pid=${_job%%:*}
+	wait "$_pid" 2>/dev/null || :
+done
+sync
+
+if [[ $_status_error == yes ]]; then
+	result_loop 'ERROR: wipe status unavailable.' 3 1
+fi
+
+_failed=
+for _disk in $_disks; do
+	_status_file=$_status_dir/$_disk
+	[[ $(<"$_status_file") == ok ]] ||
+	    _failed="${_failed}${_failed:+ }$_disk"
+done
+
+if [[ -n $_failed ]]; then
+	result_loop "ERROR: wipe failed: $_failed" 3 1
+fi
+
+result_loop 'WIPE COMPLETE: all selected disks were overwritten.' 5 0.25
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/bsddd/dot.profile
@@ -0,0 +1,16 @@
+set +o sh
+export PATH=/sbin:/bin:/usr/bin:/usr/sbin:/
+
+if ! mount -u /dev/rd0a /; then
+	echo "Cannot remount rd0a read-write."
+	while :; do
+		sleep 3600
+	done
+fi
+
+exec /bsddd
+
+echo "Cannot start /bsddd."
+while :; do
+	sleep 3600
+done
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/bsddd/list.dd
@@ -0,0 +1,7 @@
+#	$OpenBSD$
+
+# bsddd.rd overlay.
+SCRIPT	${CURDIR}/bsddd/dot.profile		.profile
+SCRIPT	${OBJDIR}/bsddd				bsddd
+SPECIAL	chmod 555 bsddd
+SPECIAL	cd dev; sh MAKEDEV speaker

SYNOPSIS

Build on the matching OpenBSD/amd64 release after completing the normal source and release prerequisites. BSDOBJDIR and the amd64 boot files below ${DESTDIR}/usr/mdec must exist. The documented commands require root privileges.

Apply the patch below /usr/src, create the private object link, then build.

Buildraw
set -e

cd /usr/src
patch -p1 < /root/bsddd.rd.patch
cd distrib/amd64/ramdisk_cd/bsddd
make -f Makefile.bsddd obj
make -f Makefile.bsddd

Select pseudorandom input with BSDDD_DD_IF=/dev/urandom.

Build with pseudorandom inputraw
cd /usr/src/distrib/amd64/ramdisk_cd/bsddd
make -f Makefile.bsddd BSDDD_DD_IF=/dev/urandom

Enable a 300-second all-disk timeout with make -f Makefile.bsddd BSDDD_WIPE_TIMEOUT=300.

Install release artifacts when RELEASEDIR is set.

Install release artifactsraw
set -e

cd /usr/src/distrib/amd64/ramdisk_cd/bsddd
make -f Makefile.bsddd install-bsddd \
    RELEASEDIR=/path/to/release

SOURCE LAYOUT

The patch adds one source directory containing six files.

Added filesraw
distrib/amd64/ramdisk_cd/bsddd/
|-- Makefile.bsddd
|-- RAMDISK_CD_DD
|-- README.bsddd
|-- bsddd
|-- dot.profile
`-- list.dd

TARGETS

bsddd
Builds bsddd.rd and minirootXX_dd.img.
install-bsddd
Installs compressed bsddd.rd and the miniroot when RELEASEDIR is set.
unconfig-bsddd
Recovers private mount and vnd(4) state after an interrupted media build. An unowned vnd(4) is not detached.
clean-bsddd, clean, cleandir
Remove private outputs without taking ownership of stock instbin objects.
prepare-unpatch-bsddd
Cleans private outputs, prints the private object path, and removes the source obj symlink.

BUILD MODEL

make obj creates a private object link below BSDOBJDIR. The custom kernel, rdroot, instbin copy, and media objects remain below that path.

The wrapper builds stock ramdisk_cd instbin objects in their normal object directories, then copies instbin into private state. Stock source, configuration, installed use, and cleanup ownership are unchanged. Do not run bsddd concurrently with a build or clean using those objects.

The private rdroot replaces .profile, installs /bsddd, and creates /dev/speaker. It adds no userland binaries. The private kernel retains the stock rdroot size and adds pcppi(4) and speaker(4).

No parent Makefile or SUBDIR list is changed. Normal builds and cleans do not enter the bsddd source directory or its private objects.

Makefile.bsdddrawโ†“
#	$OpenBSD$

.include <bsd.own.mk>

# Stock ramdisk_cd outer-image size, retained as an override point.
BSDDD_FSSIZE?=	11392
BSDDD_DD_IF?=	/dev/zero
BSDDD_WIPE_TIMEOUT?=	0

BSDDD_BASEDIR=	${.CURDIR}/..
BSDDD_TOP=	${.CURDIR}/../../../..
BSDDD_UTILS=	${BSDDD_BASEDIR}/../../miniroot
BSDDD_MTREE=	${BSDDD_UTILS}/mtree.conf
BSDDD_EFIBOOT=	${DESTDIR}/usr/mdec/BOOTX64.EFI \
		${DESTDIR}/usr/mdec/BOOTIA32.EFI
BSDDD_MOUNT_ARGS_MSDOS=	-o-s

BSDDD_RAMDISK=	RAMDISK_CD_DD
BSDDD_KERNEL=	${.OBJDIR}/bsd
BSDDD_FS=	miniroot${OSrev}_dd.img
BSDDD_BUILDOBJDIR=	${.OBJDIR}/build
BSDDD_KERNELOBJDIR=	${BSDDD_BUILDOBJDIR}/kernel/${BSDDD_RAMDISK}
BSDDD_INSTBIN=	${BSDDD_BUILDOBJDIR}/instbin
BSDDD_RDOBJDIR=	${.OBJDIR}/rdobj
BSDDD_MOUNT_POINT=	${.OBJDIR}/mnt
BSDDD_VND=	${.OBJDIR}/vnd
BSDDD_BOOT=	${.OBJDIR}/boot
BSDDD_OBJCHECK=	${.OBJDIR}/.bsddd-obj-ok
BSDDD_MAKEFILE=	${.CURDIR}/Makefile.bsddd
BSDDD_KERNELCONF=	${.CURDIR}/${BSDDD_RAMDISK}
BSDDD_BASECONF=	${BSDDD_TOP}/sys/arch/${MACHINE}/conf/RAMDISK_CD
BSDDD_KERNELCONFDEPS=	${BSDDD_BASECONF}
BSDDD_LISTS=	${BSDDD_BASEDIR}/list ${.CURDIR}/list.dd
BSDDD_SCRIPTSRC=	${.CURDIR}/bsddd
BSDDD_SCRIPT=	${BSDDD_RDOBJDIR}/bsddd
BSDDD_PROFILE=	${.CURDIR}/dot.profile
# Stock 7360-block rdroot; keep paired with RAMDISK_CD_DD MINIROOTSIZE.
BSDDD_RDROOT_SIZE?=	3680k
BSDDD_MRMAKEFSARGS?=	-s ${BSDDD_RDROOT_SIZE} \
		-o rdroot,minfree=0,bsize=4096,fsize=512,density=4096

.PHONY: bsddd
bsddd: bsddd.rd ${BSDDD_FS}

${BSDDD_OBJCHECK}:
	@if [ "${.OBJDIR}" = "${.CURDIR}" ]; then \
		echo "private object directory is not active;" >&2; \
		echo "run 'make -f Makefile.bsddd obj' separately first" >&2; \
		exit 1; \
	fi
	touch $@

${BSDDD_KERNEL}: ${BSDDD_OBJCHECK} ${BSDDD_MAKEFILE} \
	    ${BSDDD_KERNELCONF} ${BSDDD_KERNELCONFDEPS}
	install -d -o ${BUILDUSER} -g ${WOBJGROUP} ${BSDDD_KERNELOBJDIR}
	su ${BUILDUSER} -c \
	    'config -b ${BSDDD_KERNELOBJDIR} -s ${BSDDD_TOP}/sys \
	    ${BSDDD_KERNELCONF} && cd ${BSDDD_KERNELOBJDIR} && \
	    MAKEOBJDIR=${BSDDD_KERNELOBJDIR} ${MAKE} clean && \
	    exec env MAKEOBJDIR=${BSDDD_KERNELOBJDIR} ${MAKE} ${MFLAGS}'
	cp -p ${BSDDD_KERNELOBJDIR}/bsd $@

bsddd.gz: bsddd.rd
	objcopy -g -x -R .comment -R .SUNW_ctf \
	    -K rd_root_size -K rd_root_image \
	    bsddd.rd bsddd.strip
	gzip -9cn bsddd.strip > bsddd.gz

.PHONY: bsddd-stock-deps bsddd-instbin
bsddd-stock-deps: ${BSDDD_OBJCHECK} ${BSDDD_MAKEFILE}
	@_objroot=`cd ${BSDDD_BASEDIR} && ${MAKE} -V BSDOBJDIR`; \
	if [ ! -d "$$_objroot" ]; then \
		echo "normal OpenBSD object root does not exist: $$_objroot" >&2; \
		echo "create it before building bsddd" >&2; \
		exit 1; \
	fi
	cd ${BSDDD_TOP}/lib && ${MAKE} obj
	cd ${BSDDD_TOP}/distrib/special && ${MAKE} obj
	cd ${BSDDD_BASEDIR} && ${MAKE} obj
	@_srcdir=`cd ${BSDDD_BASEDIR} && pwd`; \
	_objdir=`cd ${BSDDD_BASEDIR} && ${MAKE} -V .OBJDIR`; \
	if [ "$$_objdir" = "$$_srcdir" ] || [ ! -d "$$_objdir" ]; then \
		echo "normal ramdisk_cd object directory is not active" >&2; \
		exit 1; \
	fi
	cd ${BSDDD_TOP}/distrib/special/libstubs && ${MAKE} ${MFLAGS}

bsddd-instbin: bsddd-stock-deps
	cd ${BSDDD_BASEDIR} && ${MAKE} ${MFLAGS} instbin
	install -d ${BSDDD_BUILDOBJDIR}
	@_objdir=`cd ${BSDDD_BASEDIR} && ${MAKE} -V .OBJDIR`; \
	if [ ! -f "$$_objdir/instbin" ]; then \
		echo "stock ramdisk_cd instbin was not built" >&2; \
		exit 1; \
	fi; \
	cp -p "$$_objdir/instbin" ${BSDDD_INSTBIN}

bsddd.rd: bsddd-files bsddd-instbin ${BSDDD_KERNEL}
	install -d ${BSDDD_RDOBJDIR}
	rm -f ${BSDDD_RDOBJDIR}/instbin \
	    ${BSDDD_RDOBJDIR}/mr.fs \
	    ${BSDDD_RDOBJDIR}/bsd.rd
	cp -p ${BSDDD_INSTBIN} ${BSDDD_RDOBJDIR}/instbin
	rm -rf ${BSDDD_RDOBJDIR}/mr.fs.d
	install -d -o root -g wheel ${BSDDD_RDOBJDIR}/mr.fs.d
	mtree -def ${BSDDD_MTREE} -p ${BSDDD_RDOBJDIR}/mr.fs.d -u
	CURDIR=${BSDDD_BASEDIR} OBJDIR=${BSDDD_RDOBJDIR} OSrev=${OSrev} \
	    TARGDIR=${BSDDD_RDOBJDIR}/mr.fs.d UTILS=${BSDDD_UTILS} \
	    RELEASEDIR=${RELEASEDIR} sh ${BSDDD_UTILS}/runlist.sh \
	    ${BSDDD_LISTS}
	rm ${BSDDD_RDOBJDIR}/mr.fs.d/instbin
	makefs ${BSDDD_MRMAKEFSARGS} ${BSDDD_RDOBJDIR}/mr.fs \
	    ${BSDDD_RDOBJDIR}/mr.fs.d
	cp -p ${BSDDD_KERNEL} ${BSDDD_RDOBJDIR}/bsd.rd
	rdsetroot ${BSDDD_RDOBJDIR}/bsd.rd ${BSDDD_RDOBJDIR}/mr.fs
	cp ${BSDDD_RDOBJDIR}/bsd.rd $@

${BSDDD_FS}: bsddd.gz
	-umount -f ${BSDDD_MOUNT_POINT} >/dev/null 2>&1
	@if [ -e ${BSDDD_VND} ] && [ ! -s ${BSDDD_VND} ]; then \
		rm -f ${BSDDD_VND}; \
	fi
	@if [ -e ${BSDDD_VND} ]; then \
		echo "stale private vnd state; run" \
		    "'make -f Makefile.bsddd unconfig-bsddd' first" >&2; \
		exit 1; \
	fi
	install -d ${BSDDD_MOUNT_POINT}
	dd if=/dev/zero of=${BSDDD_FS} bs=512 count=${BSDDD_FSSIZE}
	vnconfig -v ${.OBJDIR}/${BSDDD_FS} > ${BSDDD_VND}
	fdisk -yi -l ${BSDDD_FSSIZE} -b 960 -f ${DESTDIR}/usr/mdec/mbr \
	    `cat ${BSDDD_VND}`
	echo '/ *' | disklabel -wAT- `cat ${BSDDD_VND}`
	newfs -t msdos /dev/r`cat ${BSDDD_VND}`i
	mount ${BSDDD_MOUNT_ARGS_MSDOS} /dev/`cat ${BSDDD_VND}`i \
	    ${BSDDD_MOUNT_POINT}
	mkdir -p ${BSDDD_MOUNT_POINT}/efi/boot
	cp ${BSDDD_EFIBOOT} ${BSDDD_MOUNT_POINT}/efi/boot
	umount ${BSDDD_MOUNT_POINT}
	newfs -O 1 -m 0 -o space -i 524288 -c ${BSDDD_FSSIZE} \
	    /dev/r`cat ${BSDDD_VND}`a
	mount /dev/`cat ${BSDDD_VND}`a ${BSDDD_MOUNT_POINT}
	objcopy -S -R .comment ${DESTDIR}/usr/mdec/boot ${BSDDD_BOOT}
	installboot -v -r ${BSDDD_MOUNT_POINT} `cat ${BSDDD_VND}` \
	    ${DESTDIR}/usr/mdec/biosboot ${BSDDD_BOOT}
	install -c -m 555 -o root -g wheel bsddd.gz \
	    ${BSDDD_MOUNT_POINT}/bsd
	df -i ${BSDDD_MOUNT_POINT}
	umount ${BSDDD_MOUNT_POINT}
	vnconfig -u `cat ${BSDDD_VND}`
	rm -f ${BSDDD_VND}

.PHONY: bsddd-check bsddd-files
bsddd-check:
	@case "${BSDDD_DD_IF}" in \
	/dev/zero|/dev/urandom) ;; \
	*) echo "BSDDD_DD_IF must be /dev/zero or /dev/urandom" >&2; exit 1;; \
	esac
	@case "${BSDDD_WIPE_TIMEOUT}" in \
	''|*[!0-9]*|0[0-9]*) \
		echo "BSDDD_WIPE_TIMEOUT must be 0 or a positive decimal integer" >&2; \
		exit 1;; \
	esac
	@if ! [ "${BSDDD_WIPE_TIMEOUT}" -le 2147483647 ]; then \
		echo "BSDDD_WIPE_TIMEOUT exceeds 2147483647" >&2; \
		exit 1; \
	fi
	test -s ${BSDDD_SCRIPTSRC}
	test -s ${BSDDD_PROFILE}
	test -s ${.CURDIR}/list.dd

bsddd-files: ${BSDDD_OBJCHECK} bsddd-check ${BSDDD_SCRIPTSRC} \
	    ${BSDDD_PROFILE} ${.CURDIR}/list.dd
	install -d ${BSDDD_RDOBJDIR}
	rm -f ${BSDDD_SCRIPT}.tmp
	sed -e 's|^DD_IF=.*|DD_IF=${BSDDD_DD_IF}|' \
	    -e 's|^WIPE_TIMEOUT=.*|WIPE_TIMEOUT=${BSDDD_WIPE_TIMEOUT}|' \
	    ${BSDDD_SCRIPTSRC} > \
	    ${BSDDD_SCRIPT}.tmp
	grep -Fqx 'DD_IF=${BSDDD_DD_IF}' ${BSDDD_SCRIPT}.tmp
	grep -Fqx 'WIPE_TIMEOUT=${BSDDD_WIPE_TIMEOUT}' ${BSDDD_SCRIPT}.tmp
	mv ${BSDDD_SCRIPT}.tmp ${BSDDD_SCRIPT}

.PHONY: unconfig-bsddd
unconfig-bsddd:
	-umount -f ${BSDDD_MOUNT_POINT} >/dev/null 2>&1
	@if [ -e ${BSDDD_VND} ] && [ ! -s ${BSDDD_VND} ]; then \
		rm -f ${BSDDD_VND}; \
	elif [ -f ${BSDDD_VND} ]; then \
		_vnd=`cat ${BSDDD_VND}`; \
		_unit=$${_vnd#vnd}; \
		if [ "vnd$$_unit" != "$$_vnd" ] || [ -z "$$_unit" ]; then \
			echo "invalid private vnd state: $$_vnd" >&2; exit 1; \
		fi; \
		case "$$_unit" in \
		*[!0-9]*) echo "invalid private vnd state: $$_vnd" >&2; exit 1;; \
		esac; \
		_info=`vnconfig -l "$$_vnd"` || exit 1; \
		case "$$_info" in \
		"$$_vnd: not in use") rm -f ${BSDDD_VND} ;; \
		"$$_vnd: covering ${.OBJDIR}/${BSDDD_FS} on "*) \
			vnconfig -u "$$_vnd" && rm -f ${BSDDD_VND} ;; \
		*) echo "refusing to detach vnd not owned by bsddd: $$_info" >&2; \
			exit 1 ;; \
		esac; \
	fi

.ifdef RELEASEDIR
.PHONY: install-bsddd
install-bsddd: bsddd.gz ${BSDDD_FS}
	cp bsddd.gz ${RELEASEDIR}/bsddd.rd
	cp ${BSDDD_FS} ${RELEASEDIR}
	chmod a+r ${RELEASEDIR}/bsddd.rd ${RELEASEDIR}/${BSDDD_FS}
.endif

.PHONY: clean-bsddd clean cleandir
clean-bsddd: unconfig-bsddd
	rm -f bsddd.rd bsddd.gz bsddd.strip ${BSDDD_FS} \
	    ${BSDDD_KERNEL} ${BSDDD_BOOT} ${BSDDD_OBJCHECK}
	rm -rf ${BSDDD_BUILDOBJDIR} ${BSDDD_RDOBJDIR}
	-rmdir ${BSDDD_MOUNT_POINT}

clean cleandir: clean-bsddd

.PHONY: prepare-unpatch-bsddd
prepare-unpatch-bsddd: clean-bsddd
	@if [ -L ${.CURDIR}/obj ]; then \
		_obj=`readlink ${.CURDIR}/obj`; \
		echo "empty private object directory may be removed: $$_obj"; \
		rm -f ${.CURDIR}/obj; \
	fi

.include <bsd.obj.mk>
RAMDISK_CD_DDraw
#	$OpenBSD$

include "arch/amd64/conf/RAMDISK_CD"

# Retain an explicit, stock-sized rdroot override for later adjustment.
option		BSDDD_RDROOT
rmoption	MINIROOTSIZE
option		MINIROOTSIZE=7360
rmoption	BSDDD_RDROOT

pcppi0	at isa?
spkr0	at pcppi?
list.ddraw
#	$OpenBSD$

# bsddd.rd overlay.
SCRIPT	${CURDIR}/bsddd/dot.profile		.profile
SCRIPT	${OBJDIR}/bsddd				bsddd
SPECIAL	chmod 555 bsddd
SPECIAL	cd dev; sh MAKEDEV speaker
dot.profileraw
set +o sh
export PATH=/sbin:/bin:/usr/bin:/usr/sbin:/

if ! mount -u /dev/rd0a /; then
	echo "Cannot remount rd0a read-write."
	while :; do
		sleep 3600
	done
fi

exec /bsddd

echo "Cannot start /bsddd."
while :; do
	sleep 3600
done

DISK SELECTION

/bsddd reads hw.disknames with sysctl(8). It selects names matching sd[0-9]* and wd[0-9]*. This includes softraid(4) volumes, their backing disks, and disks presented through common amd64 storage controllers.

Wiping begins when the current set equals the initial set minus exactly one name. Other changes leave the ramdisk waiting. The accepted set is frozen; later arrivals are excluded. The interlock cannot identify the boot medium. Two explicit y responses or timeout expiry select every eligible attached disk. The timer continues during both prompts.

Other device classes are excluded. After the interlock, /bsddd detaches auto-assembled softraid(4) volumes and removes their names from the set. Their backing disks remain selected. Inspection or detach failure stops before writes.

MAKEDEV(8) creates device nodes. disklabel(8) supplies sector size and total sectors. dd(1) writes each whole-disk raw character device in 1 MiB transfers followed by an exact sector-sized tail. Disks run concurrently. One device failure does not stop other jobs. A status failure waits for active jobs. sync(8) precedes the final result.

/bsdddrawโ†“
#!/bin/ksh

set +o sh
export PATH=/sbin:/bin:/usr/bin:/usr/sbin:/

POLL_INTERVAL=2
READY_INTERVAL=10
PROGRESS_INTERVAL=60
RESULT_INTERVAL=30
DD_IF=/dev/zero
WIPE_TIMEOUT=0

trap '' HUP INT QUIT TSTP

beep() {
	if [[ -c /dev/speaker ]] &&
	    print -nr -- 'T240 O4 L8 C' >/dev/speaker 2>/dev/null; then
		return
	fi
	print -n '\a'
}

beep_pattern() {
	typeset _count=$1 _interval=$2

	while ((_count > 0)); do
		beep
		_count=$((_count - 1))
		((_count > 0)) && sleep "$_interval"
	done
}

result_loop() {
	typeset _message=$1 _count=$2 _interval=$3

	while :; do
		echo
		echo "$_message"
		beep_pattern "$_count" "$_interval"
		sleep $RESULT_INTERVAL
	done
}

get_disks() {
	typeset _entry _disk _disks='' _oldifs=$IFS

	IFS=,
	for _entry in $(sysctl -n hw.disknames); do
		_disk=${_entry%%:*}
		case $_disk in
		sd+([0-9])|wd+([0-9]))
			_disks="${_disks}${_disks:+ }$_disk"
			;;
		esac
	done
	IFS=$_oldifs
	print -r -- "$_disks"
}

compare_disks() {
	typeset _disk

	_removed=''
	_removed_count=0
	_added_count=0
	for _disk in $_initial_disks; do
		case " $_current_disks " in
		*" $_disk "*) ;;
		*)
			_removed="${_removed}${_removed:+ }$_disk"
			_removed_count=$((_removed_count + 1))
			;;
		esac
	done
	for _disk in $_current_disks; do
		case " $_initial_disks " in
		*" $_disk "*) ;;
		*)
			_added_count=$((_added_count + 1))
			;;
		esac
	done
}

start_reader() {
	rm -f /tmp/bsddd.answer
	(
		typeset _answer
		IFS= read -r _answer </dev/console || _answer=
		if [[ $_answer == [yY] ]]; then
			print -n 'Wipe every attached sd/wd disk, including boot media? [y/N] ' \
			    >/dev/console
			IFS= read -r _answer </dev/console || _answer=
		fi
		print -r -- "$_answer" > /tmp/bsddd.answer
	) &
	_reader=$!
}

stop_reader() {
	kill "$_reader" 2>/dev/null || :
	wait "$_reader" 2>/dev/null || :
	rm -f /tmp/bsddd.answer
}

wipe_disk() {
	typeset _disk=$1 _geometry _sector_size _sectors
	typeset _sectors_per_mib _full _tail _raw

	(cd /dev && sh MAKEDEV "$_disk") || return 1
	_raw=/dev/r${_disk}c
	[[ -c $_raw ]] || return 1

	_geometry=$(disklabel -d "$_disk" 2>/dev/null) || return 1
	_sector_size=$(print -r -- "$_geometry" |
	    sed -n 's/^bytes\/sector:[[:space:]]*//p')
	_sectors=$(print -r -- "$_geometry" |
	    sed -n 's/^total sectors:[[:space:]]*\([0-9][0-9]*\).*/\1/p')

	case $_sector_size in
	''|*[!0-9]*) return 1 ;;
	esac
	case $_sectors in
	''|*[!0-9]*) return 1 ;;
	esac
	((_sector_size > 0 && _sectors > 0)) || return 1

	echo "$_disk: overwriting $_sectors sectors at $_sector_size bytes"

	if ((1048576 % _sector_size != 0)); then
		dd if="$DD_IF" of="$_raw" bs="$_sector_size" count="$_sectors" \
		    status=none || return 1
	else
		_sectors_per_mib=$((1048576 / _sector_size))
		_full=$((_sectors / _sectors_per_mib))
		_tail=$((_sectors % _sectors_per_mib))

		if ((_full > 0)); then
			dd if="$DD_IF" of="$_raw" bs=1m count="$_full" \
			    status=none || return 1
		fi
		if ((_tail > 0)); then
			dd if="$DD_IF" of="$_raw" bs="$_sector_size" \
			    seek="$((_full * _sectors_per_mib))" count="$_tail" \
			    status=none || return 1
		fi
	fi

	echo "$_disk: complete"
}

_initial_disks=$(get_disks)
if [[ -z $_initial_disks ]]; then
	result_loop 'ERROR: no eligible sd(4) or wd(4) disks were found.' 3 1
fi

echo
echo "bsddd.rd is resident in memory."
echo "Detected disks: $_initial_disks"
echo "Remove exactly one disk to begin automatically."
_timeout_deadline=0
if ((WIPE_TIMEOUT > 0)); then
	echo "Automatic all-disk wipe in $WIPE_TIMEOUT seconds."
	_timeout_deadline=$((SECONDS + WIPE_TIMEOUT))
fi
echo "Proceed without detected device removal? [y/N]"

start_reader
_ready_elapsed=$READY_INTERVAL
_last_disks=$_initial_disks
_override=no

while :; do
	_current_disks=$(get_disks)
	compare_disks
	if ((_removed_count == 1 && _added_count == 0)); then
		stop_reader
		_disks=$_current_disks
		echo
		echo "Removed disk: $_removed"
		break
	fi

	if [[ $_current_disks != "$_last_disks" ]]; then
		echo "Waiting; current disks: ${_current_disks:-none}"
		_last_disks=$_current_disks
	fi

	if [[ -s /tmp/bsddd.answer ]]; then
		_answer=$(</tmp/bsddd.answer)
		wait "$_reader" 2>/dev/null || :
		rm -f /tmp/bsddd.answer
		if [[ $_answer == [yY] ]]; then
			_override=console
			_disks=$(get_disks)
			break
		fi
		echo "Proceed without detected device removal? [y/N]"
		start_reader
	elif ! kill -0 "$_reader" 2>/dev/null; then
		wait "$_reader" 2>/dev/null || :
		start_reader
	fi

	if ((_timeout_deadline > 0 && SECONDS >= _timeout_deadline)); then
		stop_reader
		_override=timeout
		_disks=$(get_disks)
		break
	fi

	if ((_ready_elapsed >= READY_INTERVAL)); then
		if ((_timeout_deadline > 0)); then
			beep_pattern 4 0.15
		else
			beep_pattern 3 0.25
		fi
		_ready_elapsed=0
	fi
	_sleep=$POLL_INTERVAL
	if ((_timeout_deadline > 0)); then
		_remaining=$((_timeout_deadline - SECONDS))
		((_remaining < _sleep)) && _sleep=$_remaining
		((_sleep > 0)) || continue
	fi
	sleep "$_sleep"
	_ready_elapsed=$((_ready_elapsed + _sleep))
done

if ! _softraid_output=$(bioctl softraid0 2>/dev/null); then
	result_loop 'ERROR: cannot inspect softraid volumes.' 3 1
fi
_softraid_volumes=$(print -r -- "$_softraid_output" |
    sed -n 's/^softraid0.*\(sd[0-9][0-9]*\).*/\1/p')
for _volume in $_softraid_volumes; do
	echo "$_volume: detaching softraid volume"
	if ! bioctl -d "$_volume" >/dev/null 2>&1; then
		result_loop "ERROR: cannot detach softraid volume: $_volume" 3 1
	fi
done

_wipe_disks=
for _disk in $_disks; do
	case " $_softraid_volumes " in
	*" $_disk "*) ;;
	*) _wipe_disks="${_wipe_disks}${_wipe_disks:+ }$_disk" ;;
	esac
done
_disks=$_wipe_disks
if [[ -z $_disks ]]; then
	result_loop 'ERROR: no eligible sd(4) or wd(4) disks remain.' 3 1
fi

_status_dir=/tmp/bsddd.status
if ! rm -rf "$_status_dir" || ! mkdir "$_status_dir"; then
	result_loop 'ERROR: cannot initialize wipe status storage.' 3 1
fi
for _disk in $_disks; do
	_status_file=$_status_dir/$_disk
	_status_ok=${_status_file}.ok
	_status_failed=${_status_file}.failed
	if ! print -r -- running > "$_status_file" ||
	    ! print -r -- ok > "$_status_ok" ||
	    ! print -r -- failed > "$_status_failed" ||
	    [[ $(<"$_status_file") != running ]] ||
	    [[ $(<"$_status_ok") != ok ]] ||
	    [[ $(<"$_status_failed") != failed ]]; then
		result_loop 'ERROR: cannot initialize wipe status storage.' 3 1
	fi
done

echo
if [[ $_override == console ]]; then
	echo "Console override confirmed."
elif [[ $_override == timeout ]]; then
	echo "Wipe timeout expired; all attached sd/wd disks selected."
fi
echo "Wiping disks in parallel: $_disks"
echo "Wipe input: $DD_IF"
echo "DO NOT POWER OFF."
beep_pattern 2 1

_jobs=
_total=0
for _disk in $_disks; do
	(
		_status_file=$_status_dir/$_disk
		if wipe_disk "$_disk"; then
			mv "${_status_file}.ok" "$_status_file" || exit 2
			exit 0
		else
			mv "${_status_file}.failed" "$_status_file" || exit 2
			exit 1
		fi
	) &
	_jobs="${_jobs}${_jobs:+ }$!:$_disk"
	_total=$((_total + 1))
done

_elapsed=0
_status_error=no
while :; do
	_done=0
	for _job in $_jobs; do
		_pid=${_job%%:*}
		_disk=${_job#*:}
		_status_file=$_status_dir/$_disk
		if [[ ! -r $_status_file ]]; then
			_status_error=yes
			break
		fi
		_status=$(<"$_status_file")
		case $_status in
		ok|failed)
			_done=$((_done + 1))
			;;
		running)
			if ! kill -0 "$_pid" 2>/dev/null; then
				_status_error=yes
				break
			fi
			;;
		*)
			_status_error=yes
			break
			;;
		esac
	done
	[[ $_status_error == yes ]] && break
	((_done == _total)) && break

	sleep $POLL_INTERVAL
	_elapsed=$((_elapsed + POLL_INTERVAL))
	if ((_elapsed >= PROGRESS_INTERVAL)); then
		echo "Wipe in progress: $_done/$_total disks complete."
		beep_pattern 1 0
		_elapsed=0
	fi
done

if [[ $_status_error == yes ]]; then
	echo "ERROR: wipe status unavailable; waiting for disk jobs."
fi
for _job in $_jobs; do
	_pid=${_job%%:*}
	wait "$_pid" 2>/dev/null || :
done
sync

if [[ $_status_error == yes ]]; then
	result_loop 'ERROR: wipe status unavailable.' 3 1
fi

_failed=
for _disk in $_disks; do
	_status_file=$_status_dir/$_disk
	[[ $(<"$_status_file") == ok ]] ||
	    _failed="${_failed}${_failed:+ }$_disk"
done

if [[ -n $_failed ]]; then
	result_loop "ERROR: wipe failed: $_failed" 3 1
fi

result_loop 'WIPE COMPLETE: all selected disks were overwritten.' 5 0.25

FEEDBACK

Three short bells
Waiting without a timeout.
Four rapid bells
Waiting with an all-disk timeout.
Two separated bells
Disk writes started.
One bell
Wipe progress.
Five short bells
All selected disks completed.
Three separated bells
Setup, status, or disk-write failure.

Ready patterns repeat every 10 seconds. Completion and failure patterns repeat every 30 seconds. Progress is reported every 60 seconds. A console bell is used if speaker output fails. The result loop continues until power-off or reset.

USE

As root, write the miniroot to removable media. This replaces the selected device's partition table and filesystems. Verify the output device.

Write media from OpenBSDraw
set -e

dd if=./miniroot79_dd.img of=/dev/rsdXc bs=1m
sync

Boot the target. At the ready pattern, remove the boot medium. Removal of any eligible disk satisfies the name-based interlock. Two separated bells mark write startup. Wait for the repeating completion pattern before power-off. Stock amd64 BIOS and UEFI boot programs are used. Secure Boot is unsupported.

A boot loader or matching OpenBSD/amd64 pxeboot(8) may load the compressed bsddd.rd directly. Without removable media, use the console confirmations.

REBUILD AND CLEAN

Clean and rebuild private outputs.

Rebuildraw
set -e

cd /usr/src/distrib/amd64/ramdisk_cd/bsddd
make -f Makefile.bsddd clean
make -f Makefile.bsddd

Run unconfig-bsddd first after an interrupted media build. Prepare private state before reversal.

Clean and reverse patchraw
set -e

cd /usr/src/distrib/amd64/ramdisk_cd/bsddd
make -f Makefile.bsddd prepare-unpatch-bsddd
cd /usr/src
patch -R -p1 < /root/bsddd.rd.patch
rmdir distrib/amd64/ramdisk_cd/bsddd

VARIABLES

BSDDD_DD_IF
Build-time dd(1) input device. Default: /dev/zero. /dev/urandom supplies non-blocking pseudorandom data but may limit throughput. No other value is accepted.
BSDDD_WIPE_TIMEOUT
Build-time automatic all-disk deadline in seconds. Default: 0 (disabled). Values from 1 through 2147483647 enable it. Expiry selects every eligible attached disk, including boot media.
BSDDD_FSSIZE
Outer media size in 512-byte blocks. Default: 11392, matching stock ramdisk_cd.
BSDDD_RDROOT_SIZE
Private rdroot size. Default: 3680k, matching 7360 512-byte blocks. Overrides must match MINIROOTSIZE.
BSDDD_MRMAKEFSARGS
makefs(8) arguments for the private rdroot. The default uses BSDDD_RDROOT_SIZE and stock filesystem parameters.
BSDOBJDIR
Normal object root and base for the private object link. Default: /usr/obj.
DESTDIR
Optional prefix for amd64 boot files under usr/mdec. Default: empty.
RELEASEDIR
Enables install-bsddd and names its destination.

FILES

obj/bsddd.rd
Uncompressed ramdisk kernel.
obj/bsddd.gz
Compressed kernel installed as bsddd.rd.
obj/minirootXX_dd.img
Bootable BIOS/UEFI whole-device image.

CAVEATS

This operation is irreversible. Console confirmation and timeout expiry select every eligible attached disk, including boot media.

A single logical overwrite pass is not a hardware secure erase. Remapped sectors, flash translation layers, controller caches, hidden regions, and inaccessible or unsupported devices may retain data. Use device-native sanitize, secure-erase, or cryptographic-erasure procedures when the required disposal policy covers those areas.

Audible feedback requires a working PC speaker or console bell. Verify feedback and storage-controller support before unattended use.

SEE ALSO

bioctl(8), dd(1), disklabel(8), make(1), MAKEDEV(8), makefs(8), pcppi(4), pxeboot(8), random(4), rdsetroot(8), speaker(4), sync(8), sysctl(8), vnconfig(8)

Patch Notes

bsdsshd.rd

OpenBSD/amd64 installer ramdisk and miniroot targets with sshd access.

Downloadbsdsshd.rd.patchApplies below /usr/src46 KiBSHA256 (bsdsshd.rd.patch) = 155372ff85ab2b04a2be06bec0b2c86a9cab1397d400c27b5e51e697d1c828f1

DESCRIPTION

The patch adds separate bsdsshd.rd and minirootXX_sshd.img targets. The ramdisk configures networking from /auto_install.conf, starts sshd, and leaves the installer environment available to the remote root user.

The default retains the local installer menu and shell. RDSSHD_CONSOLE_LOCK=yes removes the local installer userland session. Boot-loader and kernel console output remain visible.

The target supports remote storage preparation and installation, including softraid(4) work with bioctl(8). Use autoinstall(8), install.site(5), and siteXX.tgz for fully unattended installation.

The patch adds files only. Stock source files, Makefiles, bsd.rd, minirootXX.img, and cdXX.iso targets are unchanged. Enhanced targets are available only through Makefile.rdsshd.

PATCH

The aggregate patch contains the build wrapper, kernel configurations, overlay patches, ramdisk files, and plain-text reference.

bsdsshd.rd.patchrawโ†“
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/Makefile.rdsshd
@@ -0,0 +1,411 @@
+#	$OpenBSD$
+
+.include <bsd.own.mk>
+
+RDSSHD_AUTHORIZED_KEYS?=
+RDSSHD_HOST_KEY?=
+RDSSHD_CONSOLE_LOCK?=	no
+RDSSHD_KERNEL_STACK_PROTECTOR?=	yes
+RDSSHD_PORT?=	22
+RDSSHD_AI_IF?=	em0
+RDSSHD_AI_HOSTNAME?=	rdinstall
+RDSSHD_AI_IPV4?=	autoconf
+RDSSHD_AI_NETMASK?=	255.255.255.0
+RDSSHD_AI_ROUTE?=	none
+RDSSHD_AI_IPV6?=	none
+RDSSHD_AI_DOMAIN?=	my.domain
+RDSSHD_AI_DNS?=	none
+RDSSHD_FSSIZE?=	32768
+
+RDSSHD_BASEDIR=	${.CURDIR}/..
+RDSSHD_TOP=	${.CURDIR}/../../../..
+RDSSHD_UTILS=	${RDSSHD_BASEDIR}/../../miniroot
+RDSSHD_MTREE=	${RDSSHD_UTILS}/mtree.conf
+RDSSHD_EFIBOOT=	${DESTDIR}/usr/mdec/BOOTX64.EFI \
+		${DESTDIR}/usr/mdec/BOOTIA32.EFI
+RDSSHD_MOUNT_ARGS_MSDOS=	-o-s
+
+RDSSHD_RAMDISK=	RAMDISK_CD_SSHD
+RDSSHD_KERNEL=	${.OBJDIR}/bsd
+.if ${RDSSHD_KERNEL_STACK_PROTECTOR:L} == "no"
+RDSSHD_RAMDISK=	RAMDISK_CD_SSHD_NO_PROPOLICE
+RDSSHD_KERNEL=	${.OBJDIR}/bsd.no-propolice
+.endif
+RDSSHD_FS=	miniroot${OSrev}_sshd.img
+RDSSHD_BUILDOBJDIR=	${.OBJDIR}/build
+RDSSHD_KERNELOBJDIR=	${RDSSHD_BUILDOBJDIR}/kernel/${RDSSHD_RAMDISK}
+RDSSHD_INSTBIN=	${RDSSHD_BUILDOBJDIR}/instbin
+RDSSHD_STAGE=	${.OBJDIR}/stage
+RDSSHD_RDOBJDIR=	${.OBJDIR}/rdobj
+RDSSHD_SSHOBJDIR=	${.OBJDIR}/sshobj
+RDSSHD_INITOBJDIR=	${.OBJDIR}/initobj
+RDSSHD_INITSRCDIR=	${.OBJDIR}/initsrc
+RDSSHD_MOUNT_POINT=	${.OBJDIR}/mnt
+RDSSHD_VND=	${.OBJDIR}/vnd
+RDSSHD_BOOT=	${.OBJDIR}/boot
+RDSSHD_OBJCHECK=	${.OBJDIR}/.rdsshd-obj-ok
+RDSSHD_MAKEFILE=	${.CURDIR}/Makefile.rdsshd
+RDSSHD_INITMAKEFILE=	${.CURDIR}/Makefile.init
+RDSSHD_KERNELCONF=	${.CURDIR}/${RDSSHD_RAMDISK}
+RDSSHD_BASECONF=	${RDSSHD_TOP}/sys/arch/${MACHINE}/conf/RAMDISK_CD
+RDSSHD_KERNELCONFDEPS=	${RDSSHD_BASECONF}
+RDSSHD_LISTS=	${RDSSHD_BASEDIR}/list ${.CURDIR}/list.sshd \
+		${RDSSHD_STAGE}/list.console
+RDSSHD_INITSRC=	${RDSSHD_INITSRCDIR}/init.c
+RDSSHD_INIT=	${RDSSHD_STAGE}/init
+RDSSHD_INSTALLERPATCH=	${.CURDIR}/installer.sshd.patch
+RDSSHD_CONSOLEPATCH=	${.CURDIR}/console-lock.sshd.patch
+RDSSHD_INITPATCH=	${.CURDIR}/init.sshd.patch
+RDSSHD_SSHDCONF=	${.CURDIR}/sshd_config
+RDSSHD_MRMAKEFSARGS?=	-s 20m \
+		-o rdroot,minfree=0,bsize=4096,fsize=512,density=4096
+RDSSHD_BINS=	${RDSSHD_STAGE}/bin/sshd \
+		${RDSSHD_STAGE}/bin/ssh-keygen \
+		${RDSSHD_STAGE}/bin/sshd-session \
+		${RDSSHD_STAGE}/bin/sshd-auth
+
+RDSSHD_INITDEP=
+.if ${RDSSHD_CONSOLE_LOCK:L} == "yes"
+RDSSHD_INITDEP=	${RDSSHD_INIT}
+.endif
+
+.PHONY: rdsshd
+rdsshd: bsdsshd.rd ${RDSSHD_FS}
+
+.PHONY: rdsshd-objcheck
+rdsshd-objcheck: ${RDSSHD_OBJCHECK}
+
+${RDSSHD_OBJCHECK}:
+	@if [ "${.OBJDIR}" = "${.CURDIR}" ]; then \
+		echo "private object directory is not active;" >&2; \
+		echo "run 'make -f Makefile.rdsshd obj' separately first" >&2; \
+		exit 1; \
+	fi
+	touch $@
+
+${RDSSHD_KERNEL}: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE} \
+	    ${RDSSHD_KERNELCONF} ${RDSSHD_KERNELCONFDEPS}
+	install -d -o ${BUILDUSER} -g ${WOBJGROUP} ${RDSSHD_KERNELOBJDIR}
+	su ${BUILDUSER} -c \
+	    'config -b ${RDSSHD_KERNELOBJDIR} -s ${RDSSHD_TOP}/sys \
+	    ${RDSSHD_KERNELCONF} && cd ${RDSSHD_KERNELOBJDIR} && \
+	    MAKEOBJDIR=${RDSSHD_KERNELOBJDIR} ${MAKE} clean && \
+	    exec env MAKEOBJDIR=${RDSSHD_KERNELOBJDIR} ${MAKE} ${MFLAGS}'
+	cp -p ${RDSSHD_KERNELOBJDIR}/bsd $@
+
+bsdsshd.gz: bsdsshd.rd
+	objcopy -g -x -R .comment -R .SUNW_ctf \
+	    -K rd_root_size -K rd_root_image \
+	    bsdsshd.rd bsdsshd.strip
+	gzip -9cn bsdsshd.strip > bsdsshd.gz
+.if !empty(RDSSHD_HOST_KEY)
+	chmod 600 bsdsshd.strip $@
+.endif
+
+.PHONY: rdsshd-stock-deps rdsshd-instbin
+rdsshd-stock-deps: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
+	@_objroot=`cd ${RDSSHD_BASEDIR} && ${MAKE} -V BSDOBJDIR`; \
+	if [ ! -d "$$_objroot" ]; then \
+		echo "normal OpenBSD object root does not exist: $$_objroot" >&2; \
+		echo "create it before building rdsshd" >&2; \
+		exit 1; \
+	fi
+	cd ${RDSSHD_TOP}/lib && ${MAKE} obj
+	cd ${RDSSHD_TOP}/distrib/special && ${MAKE} obj
+	cd ${RDSSHD_BASEDIR} && ${MAKE} obj
+	@_srcdir=`cd ${RDSSHD_BASEDIR} && pwd`; \
+	_objdir=`cd ${RDSSHD_BASEDIR} && ${MAKE} -V .OBJDIR`; \
+	if [ "$$_objdir" = "$$_srcdir" ] || [ ! -d "$$_objdir" ]; then \
+		echo "normal ramdisk_cd object directory is not active" >&2; \
+		exit 1; \
+	fi
+	cd ${RDSSHD_TOP}/distrib/special/libstubs && ${MAKE} ${MFLAGS}
+
+rdsshd-instbin: rdsshd-stock-deps
+	cd ${RDSSHD_BASEDIR} && ${MAKE} ${MFLAGS} instbin
+	install -d ${RDSSHD_BUILDOBJDIR}
+	@_objdir=`cd ${RDSSHD_BASEDIR} && ${MAKE} -V .OBJDIR`; \
+	if [ ! -f "$$_objdir/instbin" ]; then \
+		echo "stock ramdisk_cd instbin was not built" >&2; \
+		exit 1; \
+	fi; \
+	cp -p "$$_objdir/instbin" ${RDSSHD_INSTBIN}
+
+bsdsshd.rd: rdsshd-files ${RDSSHD_INITDEP} rdsshd-instbin \
+	    ${RDSSHD_KERNEL}
+	install -d ${RDSSHD_RDOBJDIR}
+	rm -f ${RDSSHD_RDOBJDIR}/instbin \
+	    ${RDSSHD_RDOBJDIR}/mr.fs \
+	    ${RDSSHD_RDOBJDIR}/bsd.rd
+	cp -p ${RDSSHD_INSTBIN} ${RDSSHD_RDOBJDIR}/instbin
+	rm -rf ${RDSSHD_RDOBJDIR}/mr.fs.d
+	install -d -o root -g wheel ${RDSSHD_RDOBJDIR}/mr.fs.d
+	mtree -def ${RDSSHD_MTREE} -p ${RDSSHD_RDOBJDIR}/mr.fs.d -u
+	CURDIR=${RDSSHD_BASEDIR} OBJDIR=${RDSSHD_RDOBJDIR} OSrev=${OSrev} \
+	    TARGDIR=${RDSSHD_RDOBJDIR}/mr.fs.d UTILS=${RDSSHD_UTILS} \
+	    RELEASEDIR=${RELEASEDIR} sh ${RDSSHD_UTILS}/runlist.sh \
+	    ${RDSSHD_LISTS}
+	rm ${RDSSHD_RDOBJDIR}/mr.fs.d/instbin
+	makefs ${RDSSHD_MRMAKEFSARGS} ${RDSSHD_RDOBJDIR}/mr.fs \
+	    ${RDSSHD_RDOBJDIR}/mr.fs.d
+	cp -p ${RDSSHD_KERNEL} ${RDSSHD_RDOBJDIR}/bsd.rd
+	rdsetroot ${RDSSHD_RDOBJDIR}/bsd.rd ${RDSSHD_RDOBJDIR}/mr.fs
+	cp ${RDSSHD_RDOBJDIR}/bsd.rd $@
+.if !empty(RDSSHD_HOST_KEY)
+	chmod 600 ${RDSSHD_RDOBJDIR}/mr.fs \
+	    ${RDSSHD_RDOBJDIR}/bsd.rd $@
+.endif
+
+${RDSSHD_FS}: bsdsshd.gz
+	-umount -f ${RDSSHD_MOUNT_POINT} >/dev/null 2>&1
+	@if [ -e ${RDSSHD_VND} ] && [ ! -s ${RDSSHD_VND} ]; then \
+		rm -f ${RDSSHD_VND}; \
+	fi
+	@if [ -e ${RDSSHD_VND} ]; then \
+		echo "stale private vnd state; run" \
+		    "'make -f Makefile.rdsshd unconfig-rdsshd' first" >&2; \
+		exit 1; \
+	fi
+	install -d ${RDSSHD_MOUNT_POINT}
+	dd if=/dev/zero of=${RDSSHD_FS} bs=512 count=${RDSSHD_FSSIZE}
+	vnconfig -v ${.OBJDIR}/${RDSSHD_FS} > ${RDSSHD_VND}
+	fdisk -yi -l ${RDSSHD_FSSIZE} -b 960 -f ${DESTDIR}/usr/mdec/mbr \
+	    `cat ${RDSSHD_VND}`
+	echo '/ *' | disklabel -wAT- `cat ${RDSSHD_VND}`
+	newfs -t msdos /dev/r`cat ${RDSSHD_VND}`i
+	mount ${RDSSHD_MOUNT_ARGS_MSDOS} /dev/`cat ${RDSSHD_VND}`i \
+	    ${RDSSHD_MOUNT_POINT}
+	mkdir -p ${RDSSHD_MOUNT_POINT}/efi/boot
+	cp ${RDSSHD_EFIBOOT} ${RDSSHD_MOUNT_POINT}/efi/boot
+	umount ${RDSSHD_MOUNT_POINT}
+	newfs -O 1 -m 0 -o space -i 524288 -c ${RDSSHD_FSSIZE} \
+	    /dev/r`cat ${RDSSHD_VND}`a
+	mount /dev/`cat ${RDSSHD_VND}`a ${RDSSHD_MOUNT_POINT}
+	objcopy -S -R .comment ${DESTDIR}/usr/mdec/boot ${RDSSHD_BOOT}
+	installboot -v -r ${RDSSHD_MOUNT_POINT} `cat ${RDSSHD_VND}` \
+	    ${DESTDIR}/usr/mdec/biosboot ${RDSSHD_BOOT}
+	install -c -m 555 -o root -g wheel bsdsshd.gz \
+	    ${RDSSHD_MOUNT_POINT}/bsd
+	df -i ${RDSSHD_MOUNT_POINT}
+	umount ${RDSSHD_MOUNT_POINT}
+	vnconfig -u `cat ${RDSSHD_VND}`
+	rm -f ${RDSSHD_VND}
+.if !empty(RDSSHD_HOST_KEY)
+	chmod 600 $@
+.endif
+
+.PHONY: rdsshd-check rdsshd-files rdsshd-scripts
+rdsshd-check:
+	@if [ -z "${RDSSHD_AUTHORIZED_KEYS}" ]; then \
+		echo "set RDSSHD_AUTHORIZED_KEYS to a public key file" >&2; \
+		exit 1; \
+	fi
+	@case "${RDSSHD_CONSOLE_LOCK:L}" in \
+	yes|no) ;; \
+	*) echo "RDSSHD_CONSOLE_LOCK must be yes or no" >&2; exit 1;; \
+	esac
+	@case "${RDSSHD_KERNEL_STACK_PROTECTOR:L}" in \
+	yes|no) ;; \
+	*) echo "RDSSHD_KERNEL_STACK_PROTECTOR must be yes or no" >&2; exit 1;; \
+	esac
+
+${RDSSHD_INITSRC}: ${RDSSHD_OBJCHECK} ${RDSSHD_TOP}/sbin/init/init.c \
+	    ${RDSSHD_INITPATCH}
+	install -d ${RDSSHD_INITSRCDIR}
+	rm -f ${RDSSHD_INITSRC}.tmp ${RDSSHD_INITSRC}.tmp.orig \
+	    ${RDSSHD_INITSRC}.tmp.rej
+	cp ${RDSSHD_TOP}/sbin/init/init.c ${RDSSHD_INITSRC}.tmp
+	cd ${RDSSHD_INITSRCDIR} && \
+	    patch -f -s -p0 -F0 < ${RDSSHD_INITPATCH}
+	mv ${RDSSHD_INITSRC}.tmp ${RDSSHD_INITSRC}
+
+${RDSSHD_INIT}: ${RDSSHD_INITSRC} \
+	    ${RDSSHD_TOP}/sbin/init/pathnames.h ${RDSSHD_INITMAKEFILE} \
+	    ${RDSSHD_BASEDIR}/../../special/init/Makefile \
+	    ${RDSSHD_BASEDIR}/../../special/Makefile.inc
+	install -d ${RDSSHD_INITOBJDIR} ${RDSSHD_STAGE}
+	cd ${RDSSHD_BASEDIR}/../../special/init && \
+	    MAKEOBJDIR=${RDSSHD_INITOBJDIR} ${MAKE} ${MFLAGS} \
+	    -f ${RDSSHD_INITMAKEFILE} \
+	    RDSSHD_INITSRCDIR=${RDSSHD_INITSRCDIR} init
+	install -c -s ${RDSSHD_INITOBJDIR}/init $@
+
+rdsshd-scripts: ${RDSSHD_OBJCHECK} ${RDSSHD_UTILS}/install.sub \
+	    ${RDSSHD_UTILS}/dot.profile \
+	    ${RDSSHD_INSTALLERPATCH} ${RDSSHD_CONSOLEPATCH}
+	install -d ${RDSSHD_STAGE}
+	rm -f ${RDSSHD_STAGE}/install.sub.tmp \
+	    ${RDSSHD_STAGE}/install.sub.tmp.orig \
+	    ${RDSSHD_STAGE}/install.sub.tmp.rej \
+	    ${RDSSHD_STAGE}/dot.profile.tmp \
+	    ${RDSSHD_STAGE}/dot.profile.tmp.orig \
+	    ${RDSSHD_STAGE}/dot.profile.tmp.rej
+	cp ${RDSSHD_UTILS}/install.sub ${RDSSHD_STAGE}/install.sub.tmp
+	cp ${RDSSHD_UTILS}/dot.profile ${RDSSHD_STAGE}/dot.profile.tmp
+	cd ${RDSSHD_STAGE} && \
+	    patch -f -s -p0 -F0 < ${RDSSHD_INSTALLERPATCH}
+.if ${RDSSHD_CONSOLE_LOCK:L} == "yes"
+	cd ${RDSSHD_STAGE} && \
+	    patch -f -s -p0 -F0 < ${RDSSHD_CONSOLEPATCH}
+.endif
+	chmod 755 ${RDSSHD_STAGE}/install.sub.tmp
+	mv ${RDSSHD_STAGE}/install.sub.tmp ${RDSSHD_STAGE}/install.sub
+	mv ${RDSSHD_STAGE}/dot.profile.tmp ${RDSSHD_STAGE}/dot.profile
+
+rdsshd-files: rdsshd-check rdsshd-scripts ${RDSSHD_BINS} \
+	    ${RDSSHD_SSHDCONF}
+	install -d ${RDSSHD_STAGE}
+	@if [ "${RDSSHD_CONSOLE_LOCK:L}" = yes ]; then \
+		echo 'COPY ${RDSSHD_INIT} sbin/init'; \
+	fi > ${RDSSHD_STAGE}/list.console
+	sed '/^root:/s|:/bin/ksh$$|:/bin/sh|' \
+	    ${RDSSHD_UTILS}/master.passwd > ${RDSSHD_STAGE}/master.passwd
+	grep '^root:.*:/bin/sh$$' ${RDSSHD_STAGE}/master.passwd >/dev/null
+	sed -e '/^[	 ]*$$/d' -e '/^[	 ]*#/d' \
+	    < "${RDSSHD_AUTHORIZED_KEYS}" > ${RDSSHD_STAGE}/authorized_keys
+	test -s ${RDSSHD_STAGE}/authorized_keys
+	@_n=0; while IFS= read -r _key; do \
+		_n=$$((_n + 1)); \
+		if ! printf '%s\n' "$$_key" | \
+		    ${RDSSHD_STAGE}/bin/ssh-keygen -l -f - >/dev/null 2>&1; then \
+			echo "invalid public key on line $$_n of RDSSHD_AUTHORIZED_KEYS" >&2; \
+			exit 1; \
+		fi; \
+	done < ${RDSSHD_STAGE}/authorized_keys
+	grep '^sshd:' ${RDSSHD_STAGE}/master.passwd >/dev/null || \
+	    grep '^sshd:' ${RDSSHD_TOP}/etc/master.passwd >> \
+	    ${RDSSHD_STAGE}/master.passwd
+	cp ${RDSSHD_UTILS}/group ${RDSSHD_STAGE}/group
+	grep '^sshd:' ${RDSSHD_STAGE}/group >/dev/null || \
+	    grep '^sshd:' ${RDSSHD_TOP}/etc/group >> ${RDSSHD_STAGE}/group
+	{ \
+	    printf 'System hostname = %s\n' '${RDSSHD_AI_HOSTNAME}'; \
+	    printf 'Network interface to configure = %s\n' '${RDSSHD_AI_IF}'; \
+	    printf 'IPv4 address for %s = %s\n' \
+		'${RDSSHD_AI_IF}' '${RDSSHD_AI_IPV4}'; \
+	    case '${RDSSHD_AI_IPV4}' in \
+	    none|autoconf|dhcp) ;; \
+	    *) printf 'Netmask for %s = %s\n' \
+		'${RDSSHD_AI_IF}' '${RDSSHD_AI_NETMASK}'; \
+	       printf 'Default IPv4 route = %s\n' '${RDSSHD_AI_ROUTE}' ;; \
+	    esac; \
+	    printf 'IPv6 address for %s = %s\n' \
+		'${RDSSHD_AI_IF}' '${RDSSHD_AI_IPV6}'; \
+	    printf 'Network interface to configure = done\n'; \
+	    printf 'DNS domain name = %s\n' '${RDSSHD_AI_DOMAIN}'; \
+	    printf 'DNS nameservers = %s\n' '${RDSSHD_AI_DNS}'; \
+	} > ${RDSSHD_STAGE}/auto_install.conf
+	sed 's|^Port .*|Port ${RDSSHD_PORT}|' ${RDSSHD_SSHDCONF} > \
+	    ${RDSSHD_STAGE}/sshd_config
+	rm -f ${RDSSHD_STAGE}/ssh_host_ed25519_key \
+	    ${RDSSHD_STAGE}/ssh_host_ed25519_key.pub \
+	    ${RDSSHD_STAGE}/ssh_host_ed25519_key.test \
+	    ${RDSSHD_STAGE}/ssh_host_ed25519_key.test.pub \
+	    ${RDSSHD_STAGE}/sshd_config.test
+	@if [ -n "${RDSSHD_HOST_KEY}" ]; then \
+		if ! ${RDSSHD_STAGE}/bin/ssh-keygen -y -P '' \
+		    -f "${RDSSHD_HOST_KEY}" 2>/dev/null | grep -q '^ssh-ed25519 '; then \
+			echo "RDSSHD_HOST_KEY is not an unencrypted Ed25519 private key" >&2; \
+			exit 1; \
+		fi; \
+		install -c -m 600 "${RDSSHD_HOST_KEY}" \
+		    ${RDSSHD_STAGE}/ssh_host_ed25519_key; \
+	fi
+	@_key=${RDSSHD_STAGE}/ssh_host_ed25519_key; \
+	_testkey=${RDSSHD_STAGE}/ssh_host_ed25519_key.test; \
+	_testconf=${RDSSHD_STAGE}/sshd_config.test; \
+	if [[ ! -s $$_key ]]; then \
+		_key=$$_testkey; \
+		${RDSSHD_STAGE}/bin/ssh-keygen -q -t ed25519 -N '' \
+		    -f $$_key || exit 1; \
+	fi; \
+	sed "s|^HostKey .*|HostKey $$_key|" \
+	    ${RDSSHD_STAGE}/sshd_config > $$_testconf; \
+	${RDSSHD_STAGE}/bin/sshd -t -f $$_testconf; \
+	_status=$$?; \
+	rm -f $$_testconf $$_testkey $$_testkey.pub; \
+	exit $$_status
+${RDSSHD_STAGE}/bin/sshd: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
+	install -d ${RDSSHD_STAGE}/bin ${RDSSHD_SSHOBJDIR}/sshd
+	cd ${RDSSHD_TOP}/usr.bin/ssh/sshd && \
+	    MAKEOBJDIR=${RDSSHD_SSHOBJDIR}/sshd ${MAKE} ${MFLAGS} \
+	    LDSTATIC="${STATIC}" ZLIB=no
+	install -c -s ${RDSSHD_SSHOBJDIR}/sshd/sshd $@
+
+${RDSSHD_STAGE}/bin/ssh-keygen: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
+	install -d ${RDSSHD_STAGE}/bin ${RDSSHD_SSHOBJDIR}/ssh-keygen
+	cd ${RDSSHD_TOP}/usr.bin/ssh/ssh-keygen && \
+	    MAKEOBJDIR=${RDSSHD_SSHOBJDIR}/ssh-keygen ${MAKE} ${MFLAGS} \
+	    LDSTATIC="${STATIC}" ZLIB=no
+	install -c -s ${RDSSHD_SSHOBJDIR}/ssh-keygen/ssh-keygen $@
+
+${RDSSHD_STAGE}/bin/sshd-session: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
+	install -d ${RDSSHD_STAGE}/bin ${RDSSHD_SSHOBJDIR}/sshd-session
+	cd ${RDSSHD_TOP}/usr.bin/ssh/sshd-session && \
+	    MAKEOBJDIR=${RDSSHD_SSHOBJDIR}/sshd-session ${MAKE} ${MFLAGS} \
+	    LDSTATIC="${STATIC}" ZLIB=no
+	install -c -s ${RDSSHD_SSHOBJDIR}/sshd-session/sshd-session $@
+
+${RDSSHD_STAGE}/bin/sshd-auth: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
+	install -d ${RDSSHD_STAGE}/bin ${RDSSHD_SSHOBJDIR}/sshd-auth
+	cd ${RDSSHD_TOP}/usr.bin/ssh/sshd-auth && \
+	    MAKEOBJDIR=${RDSSHD_SSHOBJDIR}/sshd-auth ${MAKE} ${MFLAGS} \
+	    LDSTATIC="${STATIC}" ZLIB=no
+	install -c -s ${RDSSHD_SSHOBJDIR}/sshd-auth/sshd-auth $@
+
+.PHONY: unconfig-rdsshd
+unconfig-rdsshd:
+	-umount -f ${RDSSHD_MOUNT_POINT} >/dev/null 2>&1
+	@if [ -e ${RDSSHD_VND} ] && [ ! -s ${RDSSHD_VND} ]; then \
+		rm -f ${RDSSHD_VND}; \
+	elif [ -f ${RDSSHD_VND} ]; then \
+		_vnd=`cat ${RDSSHD_VND}`; \
+		_unit=$${_vnd#vnd}; \
+		if [ "vnd$$_unit" != "$$_vnd" ] || [ -z "$$_unit" ]; then \
+			echo "invalid private vnd state: $$_vnd" >&2; exit 1; \
+		fi; \
+		case "$$_unit" in \
+		*[!0-9]*) echo "invalid private vnd state: $$_vnd" >&2; exit 1;; \
+		esac; \
+		_info=`vnconfig -l "$$_vnd"` || exit 1; \
+		case "$$_info" in \
+		"$$_vnd: not in use") rm -f ${RDSSHD_VND} ;; \
+		"$$_vnd: covering ${.OBJDIR}/${RDSSHD_FS} on "*) \
+			vnconfig -u "$$_vnd" && rm -f ${RDSSHD_VND} ;; \
+		*) echo "refusing to detach vnd not owned by rdsshd: $$_info" >&2; \
+			exit 1 ;; \
+		esac; \
+	fi
+
+.ifdef RELEASEDIR
+.PHONY: install-rdsshd
+install-rdsshd: bsdsshd.gz ${RDSSHD_FS}
+	cp bsdsshd.gz ${RELEASEDIR}/bsdsshd.rd
+	cp ${RDSSHD_FS} ${RELEASEDIR}
+.if empty(RDSSHD_HOST_KEY)
+	chmod a+r ${RELEASEDIR}/bsdsshd.rd
+.else
+	chmod 600 ${RELEASEDIR}/bsdsshd.rd ${RELEASEDIR}/${RDSSHD_FS}
+.endif
+.endif
+
+.PHONY: clean-rdsshd clean cleandir
+clean-rdsshd: unconfig-rdsshd
+	rm -f bsdsshd.rd bsdsshd.gz bsdsshd.strip ${RDSSHD_FS} \
+	    ${.OBJDIR}/bsd ${.OBJDIR}/bsd.no-propolice ${RDSSHD_BOOT} \
+	    ${RDSSHD_OBJCHECK}
+	rm -rf ${RDSSHD_BUILDOBJDIR} ${RDSSHD_STAGE} ${RDSSHD_RDOBJDIR} \
+	    ${RDSSHD_SSHOBJDIR} ${RDSSHD_INITOBJDIR} ${RDSSHD_INITSRCDIR}
+	-rmdir ${RDSSHD_MOUNT_POINT}
+
+clean cleandir: clean-rdsshd
+
+.PHONY: prepare-unpatch-rdsshd
+prepare-unpatch-rdsshd: clean-rdsshd
+	@if [ -L ${.CURDIR}/obj ]; then \
+		_obj=`readlink ${.CURDIR}/obj`; \
+		echo "empty private object directory may be removed: $$_obj"; \
+		rm -f ${.CURDIR}/obj; \
+	fi
+
+.include <bsd.obj.mk>
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/Makefile.init
@@ -0,0 +1,5 @@
+#	$OpenBSD$
+
+.PATH: ${RDSSHD_INITSRCDIR}
+CPPFLAGS+=	-I${.CURDIR}/../../../sbin/init
+.include "${.CURDIR}/Makefile"
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/README.rdsshd
@@ -0,0 +1,599 @@
+BSDSSHD.RD                 bsdsshd.rd build notes                 BSDSSHD.RD
+
+NAME
+     bsdsshd.rd - OpenBSD/amd64 installer ramdisk with sshd access
+
+DESCRIPTION
+     The patch adds separate bsdsshd.rd and minirootXX_sshd.img targets.
+     The ramdisk configures networking from /auto_install.conf, starts sshd,
+     and leaves the installer environment available to the remote root user.
+
+     The default retains the local installer menu and shell.
+     RDSSHD_CONSOLE_LOCK=yes removes the local installer userland session.
+     Boot-loader and kernel console output remain visible.
+
+     The target supports remote storage preparation and installation, including
+     softraid(4) work with bioctl(8).  Use autoinstall(8), install.site(5), and
+     siteXX.tgz when full unattended installation is appropriate.
+
+     The patch adds files only.  Stock source files, Makefiles, bsd.rd,
+     minirootXX.img, and cdXX.iso targets are unchanged.  Enhanced targets are
+     available only through Makefile.rdsshd.
+
+SYNOPSIS
+     Run the obj target separately, then build with at least one authorized
+     public key:
+
+           make -f Makefile.rdsshd obj
+           make -f Makefile.rdsshd rdsshd \
+               RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub
+
+SOURCE LAYOUT
+     The patch adds one source directory containing eleven files:
+
+           distrib/amd64/ramdisk_cd/rdsshd/Makefile.rdsshd
+           distrib/amd64/ramdisk_cd/rdsshd/Makefile.init
+           distrib/amd64/ramdisk_cd/rdsshd/README.rdsshd
+           distrib/amd64/ramdisk_cd/rdsshd/console-lock.sshd.patch
+           distrib/amd64/ramdisk_cd/rdsshd/init.sshd.patch
+           distrib/amd64/ramdisk_cd/rdsshd/installer.sshd.patch
+           distrib/amd64/ramdisk_cd/rdsshd/list.sshd
+           distrib/amd64/ramdisk_cd/rdsshd/root.profile
+           distrib/amd64/ramdisk_cd/rdsshd/sshd_config
+           distrib/amd64/ramdisk_cd/rdsshd/RAMDISK_CD_SSHD
+           distrib/amd64/ramdisk_cd/rdsshd/RAMDISK_CD_SSHD_NO_PROPOLICE
+
+TARGETS
+     rdsshd
+             Builds bsdsshd.rd and minirootXX_sshd.img.
+
+     install-rdsshd
+             Defined when RELEASEDIR is set.  Installs compressed bsdsshd.rd
+             and minirootXX_sshd.img:
+
+                   make -f Makefile.rdsshd install-rdsshd \
+                       RELEASEDIR=/path/to/release
+
+     unconfig-rdsshd
+             Unmounts the private mount point and detaches its recorded vnd
+             after an interrupted miniroot build.
+
+     clean-rdsshd, clean, cleandir
+             Remove private rdsshd outputs.  Stock instbin objects remain under
+             normal OpenBSD clean ownership.
+
+     prepare-unpatch-rdsshd
+             Runs private cleanup, prints the wrapper object path, and removes
+             the rdsshd source obj symlink.
+
+BUILD MODEL
+     Private paths below the rdsshd object directory:
+
+           obj/build/kernel       enhanced kernel objects
+           obj/build/instbin      copy of stock instbin
+           obj/sshobj             static OpenSSH objects
+           obj/initsrc            optional patched init source
+           obj/initobj            optional private init objects
+           obj/stage              generated and overlay files
+           obj/rdobj              ramdisk assembly
+           obj/bsd*               copied enhanced kernels
+           obj/boot               miniroot boot file
+           obj/mnt                miniroot mount point
+           obj/vnd                vnd ownership record
+
+     The kernel is configured with config(8) -b and built below
+     obj/build/kernel.  No sys/arch/amd64/compile directory is added or used.
+     OpenSSH uses explicit private MAKEOBJDIR paths.
+
+     instbin is unmodified.  Its normal objects remain under:
+
+           ${BSDOBJDIR}/distrib/amd64/ramdisk_cd
+           ${BSDOBJDIR}/distrib/special
+           ${BSDOBJDIR}/lib
+
+     The wrapper runs the normal obj targets for lib, distrib/special, and
+     ramdisk_cd.  It builds stock distrib/special/libstubs, then invokes the
+     stock ramdisk_cd instbin target.  That target builds component objects and
+     reduced source libraries.  The wrapper does not transform crunchgen
+     configuration or generated Makefiles.  It copies the completed instbin to
+     obj/build/instbin before ramdisk assembly.
+
+     BSDOBJDIR must exist and retain normal OpenBSD ownership and permissions.
+     The default is /usr/obj, owned by build:wobj with mode 770.  The build
+     rejects source-directory object fallback for both the wrapper and
+     ramdisk_cd.
+
+     Normal clean and cleandir targets own the shared instbin objects.
+     clean-rdsshd owns only the private copy and enhanced outputs.  Do not run
+     rdsshd concurrently with a normal build or clean using the same instbin
+     objects.
+
+     No parent Makefile or SUBDIR list is changed.  Normal top-level builds and
+     cleans do not enter the rdsshd source directory.
+
+     Normal and enhanced media use separate boot files, mount points, image
+     names, and vnd state.  Cleanup detaches only the recorded vnd covering the
+     absolute enhanced image path.  A missing, malformed, or reused vnd is
+     reported and left attached.
+
+KERNEL
+     RAMDISK_CD_SSHD includes stock RAMDISK_CD, then applies:
+
+           rmoption NO_PROPOLICE
+           rmoption MINIROOTSIZE
+           option MINIROOTSIZE=40960
+           pseudo-device pty 16
+
+     Removing NO_PROPOLICE enables normal kernel stack protection.
+     RDSSHD_KERNEL_STACK_PROTECTOR=no selects
+     RAMDISK_CD_SSHD_NO_PROPOLICE, which retains NO_PROPOLICE.
+
+     MINIROOTSIZE=40960 reserves a 20 MiB rdroot.  Sixteen ptys support ssh
+     sessions.  SMALL_KERNEL remains enabled.
+
+     A temporary RDSSHD_RDROOT option protects config(8)'s option-list append
+     pointer while inherited tail entries are removed.  It is absent from the
+     final configuration.
+
+RAMDISK
+     Stock bsd.rd uses the host disktab entry rdrootb:
+
+           MRMAKEFSARGS=-o disklabel=rdrootb,minfree=0,density=4096
+
+     bsdsshd.rd instead gives makefs(8) an explicit 20 MiB layout:
+
+           -s 20m \
+             -o rdroot,minfree=0,bsize=4096,fsize=512,density=4096
+
+     This does not read or modify /etc/disktab.
+
+     Stock installer programs remain in instbin.  OpenSSH is built through its
+     normal Makefiles as separate static PIE executables with zlib disabled:
+
+           /usr/sbin/sshd
+           /usr/bin/ssh-keygen
+           /usr/libexec/sshd-session
+           /usr/libexec/sshd-auth
+
+     The ssh(1) client is not included.
+
+     list.sshd adds:
+
+           /etc/ssh/sshd_config
+           /root/.ssh/authorized_keys
+           /root/.profile
+           /auto_install.conf
+           /etc/login.conf
+
+     It also adds the sshd account and pty devices.  Root retains stock
+     instbin's -sh argv link.  root.profile leaves sh mode, sets the installer
+     environment, and sets TERM=vt220.  The Port directive in sshd_config is
+     substituted only in private staging.
+
+     installer.sshd.patch modifies private copies of stock .profile and
+     install.sub.  console-lock.sshd.patch adds the locked early profile path.
+     init.sshd.patch applies only to a private init.c copy.  patch(1) runs
+     non-interactively with zero fuzz.  Context drift fails before replacement.
+
+     Image layout:
+
+           embedded rdroot                 20 MiB
+           outer miniroot                  32768 x 512 bytes (16 MiB)
+
+     The outer image follows the stock amd64 miniroot layout.  It installs BIOS
+     and EFI boot files using private boot, mount, image, and vnd paths.
+
+     minirootXX_sshd.img stores compressed bsdsshd.gz as /bsd.  It does not
+     store uncompressed bsdsshd.rd.
+
+     Example amd64 artifact sizes:
+
+           boot                    87 KiB
+           bsdsshd.gz             9.0 MiB
+           bsdsshd.rd            27.2 MiB
+           bsdsshd.strip         26.7 MiB
+           minirootXX_sshd.img   16.0 MiB
+
+     These are observations, not fixed limits.  Console locking adds a private
+     static init and may increase the compressed kernel size.
+
+SSHD
+     sshd permits public-key authentication for root only.  Relevant policy:
+
+           AllowUsers root
+           PermitRootLogin prohibit-password
+           PubkeyAuthentication yes
+           AuthenticationMethods publickey
+           PasswordAuthentication no
+           KbdInteractiveAuthentication no
+           Compression no
+           PermitUserRC no
+           PrintMotd no
+           PrintLastLog no
+           DisableForwarding yes
+
+     The static PIE executables retain normal OpenBSD OpenSSH compiler and
+     linker protections.  Compression is disabled at build and run time.
+
+     RDSSHD_AUTHORIZED_KEYS is mandatory.  It may contain one or more public
+     keys.  Blank and comment lines are removed.  Every remaining line must
+     pass ssh-keygen(1) public-key validation.  The installed file has mode
+     0600; /root and /root/.ssh have mode 0700.
+
+     No host key is embedded by default.  During the build, ssh-keygen creates
+     obj/stage/ssh_host_ed25519_key.test and its public key solely to validate
+     the generated configuration with the newly built sshd -t.  The build
+     removes the test configuration and key pair after validation.  They never
+     enter the ramdisk.  An interrupted build may leave them below obj/stage;
+     the next staging pass or cleandir removes them.
+
+     On first boot, the ramdisk generates:
+
+           /etc/ssh/ssh_host_ed25519_key
+
+     Normal installer CGI fetches call feed_random before generation.  An
+     unreachable fetch delays sshd until the normal CGI timeout.
+
+     RDSSHD_HOST_KEY may name an Ed25519 host private key without a passphrase.
+     The build validates it and copies it to private staging with mode 0600.
+     The newly built sshd validates the generated configuration against this
+     staged key.  Build-host ssh configuration and keys are not read or
+     modified.
+
+     An embedded key is recoverable from private objects, kernels, images, and
+     release copies.  Key-bearing kernel and media artifacts have mode 0600.
+
+     sshd starts after donetconfig.  A live numeric PID greater than one in
+     /var/run/sshd.pid suppresses restart.  The ramdisk lacks a process
+     inspection utility.  This checks liveness, not executable identity.
+
+     install.sub creates /var/run/rdsshd.ready only after sshd starts.
+     Bootstrap profiles use this marker because installer exit status alone
+     does not establish readiness.
+
+CONSOLE AND INSTALLER
+     The initial profile invokes:
+
+           install -af /auto_install.conf
+
+     /auto_install.conf answers only the early network questions.  Static IPv4
+     configuration includes netmask and default route.  autoconf, dhcp, and
+     none omit them.
+
+     With RDSSHD_CONSOLE_LOCK=no, automatic setup begins after the normal menu
+     timeout unless a local operator selects another action.  install.sub exits
+     after starting sshd.  The profile then returns to the local ramdisk shell.
+
+     With RDSSHD_CONSOLE_LOCK=yes, a private static init replaces /sbin/init in
+     the enhanced ramdisk.  Makefile.init invokes the stock special/init
+     Makefile, supplies stock pathnames.h, and keeps patched source and objects
+     private.  The bootstrap child retains inherited /dev/null descriptors and
+     never acquires /dev/console as a controlling terminal.
+
+     The locked profile branches before terminal setup.  It retries automatic
+     network and sshd setup until /var/run/rdsshd.ready exists, then sleeps.
+     It never presents a menu or shell.  Init restarts it after exit.
+     RDSSHD_CONSOLE_LOCK=no uses stock init from instbin.
+
+     After ssh login, run:
+
+           install
+
+     This starts the interactive installer.  At network prompts, choose done to
+     retain the active configuration.  Reconfiguration can drop the ssh
+     session.
+
+CAVEATS
+     RDSSHD_CONSOLE_LOCK removes the interactive local installer userland.
+     Boot-loader and kernel consoles remain active.  A local operator can still
+     change early boot state, reset, halt, or deny remote access.
+
+     RDSSHD_KERNEL_STACK_PROTECTOR=no deliberately retains NO_PROPOLICE.  This
+     may reduce image size and weakens mitigation of kernel stack corruption.
+
+     Protect RDSSHD_HOST_KEY, the object tree, all key-bearing images, and
+     installed copies.  Use a distinct host key for each machine identity.
+
+     RDSSHD_FSSIZE is not auto-sized.  A value too small for the compressed
+     kernel and boot files causes miniroot assembly to fail.
+
+     Network variables become installer response-file answers.  The build does
+     not probe the interface or validate network reachability.  Incorrect
+     values can prevent ssh access.  A locked image then has no local installer
+     userland with which to repair the configuration.
+
+BUILD
+     Build on OpenBSD/amd64 with src.tar.gz and sys.tar.gz matching the
+     installed OpenBSD release.  Normal source-build prerequisites apply.  The
+     media target requires root privileges for vnconfig(8), mount operations,
+     device access, and file ownership.  Kernel compilation runs as BUILDUSER.
+
+     Apply the patch:
+
+           cd /usr/src
+           patch -p1 < /root/bsdsshd.rd.patch
+
+     The configured BSDOBJDIR root, normally /usr/obj, must already exist.
+     Build from the added directory:
+
+           cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
+           make -f Makefile.rdsshd obj
+           make -f Makefile.rdsshd rdsshd \
+               RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub
+
+     Run obj separately.  OpenBSD make selects .OBJDIR at startup and rejects a
+     combined obj and rdsshd invocation.  No top-level /usr/src make obj is
+     required.  The wrapper creates only the normal object links required by
+     stock instbin.  Custom components retain private paths.
+
+     Remove the local installer session:
+
+           make -f Makefile.rdsshd rdsshd \
+               RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub \
+               RDSSHD_CONSOLE_LOCK=yes
+
+     Retain NO_PROPOLICE:
+
+           make -f Makefile.rdsshd rdsshd \
+               RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub \
+               RDSSHD_KERNEL_STACK_PROTECTOR=no
+
+     Embed a stable host key:
+
+           ssh-keygen -q -t ed25519 -N '' \
+               -f /root/rdsshd_host_ed25519_key
+           make -f Makefile.rdsshd rdsshd \
+               RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub \
+               RDSSHD_HOST_KEY=/root/rdsshd_host_ed25519_key
+
+     Outputs:
+
+           /usr/src/distrib/amd64/ramdisk_cd/rdsshd/obj/bsdsshd.rd
+           /usr/src/distrib/amd64/ramdisk_cd/rdsshd/obj/minirootXX_sshd.img
+
+REBUILD AND CLEAN
+     For variable or key changes, repeat rdsshd.  Normal dependency rules reuse
+     current stock instbin components, static OpenSSH, private init, and the
+     selected kernel.  Staging, ramdisk, and media are regenerated.
+
+           make -f Makefile.rdsshd rdsshd \
+               RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub
+
+     After source, compiler, flag, or Makefile changes, clean both ownership
+     domains:
+
+           cd /usr/src/distrib/amd64/ramdisk_cd
+           make cleandir
+           cd /usr/src/distrib/special
+           make cleandir
+           cd /usr/src/lib
+           make cleandir
+
+           cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
+           make -f Makefile.rdsshd cleandir
+           make -f Makefile.rdsshd obj
+           make -f Makefile.rdsshd rdsshd \
+               RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub
+
+     A top-level cleandir may replace the three stock cleans.  The next rdsshd
+     build recreates required normal object directories.  The wrapper clean
+     never removes stock objects.
+
+     After a failed stock instbin trace link, clean ramdisk_cd before retrying.
+     Its instbin.map and reduced archives may be incomplete:
+
+           cd /usr/src/distrib/amd64/ramdisk_cd
+           make cleandir
+
+     After an interrupted miniroot build:
+
+           cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
+           make -f Makefile.rdsshd unconfig-rdsshd
+
+VARIABLES
+     RDSSHD_AUTHORIZED_KEYS
+             Required file containing one or more root authorized public keys.
+
+     RDSSHD_HOST_KEY
+             Optional Ed25519 host private key without a passphrase.  An empty
+             value generates a new host key at each boot.  Default: empty.
+
+     RDSSHD_CONSOLE_LOCK
+             yes selects private init and removes the local installer session.
+             no retains the local menu and shell.  Default: no.
+
+     RDSSHD_KERNEL_STACK_PROTECTOR
+             yes removes inherited NO_PROPOLICE.  no retains it.  Default: yes.
+
+     RDSSHD_PORT
+             sshd listen port.  The generated sshd_config must pass sshd -t.
+             Default: 22.
+
+     RDSSHD_AI_IF
+             Network interface.  Default: em0.
+
+     RDSSHD_AI_HOSTNAME
+             Hostname.  Default: rdinstall.
+
+     RDSSHD_AI_IPV4
+             IPv4 address or installer keyword.  Default: autoconf.
+
+     RDSSHD_AI_NETMASK
+             Static IPv4 netmask.  Default: 255.255.255.0.
+
+     RDSSHD_AI_ROUTE
+             Static IPv4 default route.  Default: none.
+
+     RDSSHD_AI_IPV6
+             IPv6 address or installer keyword.  Default: none.
+
+     RDSSHD_AI_DOMAIN
+             DNS domain.  Default: my.domain.
+
+     RDSSHD_AI_DNS
+             DNS nameservers.  Default: none.
+
+     RDSSHD_FSSIZE
+             Outer miniroot size in 512-byte blocks.  Default: 32768 (16 MiB).
+
+     RDSSHD_MRMAKEFSARGS
+             makefs(8) arguments for the embedded rdroot.  The default creates
+             a 20 MiB filesystem matching MINIROOTSIZE=40960.  An override must
+             fit the kernel reservation.
+
+     BSDOBJDIR
+             Normal OpenBSD object root used by stock instbin.  Default:
+             /usr/obj.
+
+     DESTDIR
+             Optional prefix for installed amd64 boot files under usr/mdec.
+             Default: empty.
+
+     RELEASEDIR
+             Enables install-rdsshd and names its destination directory.
+
+     Boolean values are case-insensitive.  Values other than yes and no are
+     rejected.
+
+USE
+     Prepare a non-interactive next boot from the running system.
+     No boot prompt or console access is assumed.
+
+     Whole-device image
+             minirootXX_sshd.img is a complete disk image.  Writing it to a
+             block device replaces the device's partition table and filesystems.
+             Verify the output device before writing it.  For example, on Linux:
+
+                   dd if=./miniroot79_sshd.img of=/dev/sda bs=512
+                   sync
+                   reboot
+
+             The running OS or storage stack may deny raw writes to the initial
+             sectors of the device backing its active root filesystem.  Use the
+             boot-loader method if this cannot be changed remotely.
+
+             The image boots in UEFI or BIOS/CSM mode with Secure Boot disabled.
+             Firmware must already select the target device.  The rdroot runs
+             from memory, so the installer can reuse that device as its target.
+
+     GRUB one-shot boot
+             Copy the uncompressed ramdisk kernel.  Do not rely on gzio:
+
+                   cp /path/to/bsdsshd.rd /boot/bsdsshd.rd
+
+             Add to /etc/grub.d/40_custom:
+
+                   menuentry "OpenBSD bsdsshd.rd" {
+                           insmod part_gpt
+                           insmod ext2
+                           insmod bsd
+                           # Replace UUID with the /boot filesystem UUID.
+                           search --no-floppy --fs-uuid --set=root UUID
+                           kopenbsd /bsdsshd.rd
+                   }
+
+             Set in /etc/default/grub:
+
+                   GRUB_DEFAULT=saved
+
+             Select a one-shot boot:
+
+                   update-grub
+                   grub-reboot "OpenBSD bsdsshd.rd"
+                   grub-editenv list
+                   sync
+                   reboot
+
+             This example was tested in BIOS/CSM mode.  UEFI is untested and
+             may lack display console output.  Secure Boot is unsupported.
+             part_gpt and ext2 match the tested /boot filesystem; change them
+             for the target layout.  The GRUB build must provide the bsd module
+             and kopenbsd command.  Other boot loaders are untested.
+
+     Network booting
+             The matching OpenBSD/amd64 pxeboot(8) can load the compressed
+             bsdsshd.rd over the network in place of bsd.rd.
+
+     Remote session
+             After the enhanced kernel boots, connect when the configured
+             address accepts ssh:
+
+                   ssh -i /path/to/private_key root@host.example
+                   install
+
+             Perform required storage preparation before running install.  At
+             network prompts, choose done to retain the active connection.
+
+             RDSSHD_CONSOLE_LOCK=yes presents no local installer menu or shell.
+
+REMOVE PATCH
+     Remove private outputs and the wrapper obj symlink before reversal:
+
+           cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
+           make -f Makefile.rdsshd prepare-unpatch-rdsshd
+           cd /usr/src
+           patch -R -p1 < /root/bsdsshd.rd.patch
+           rmdir /usr/obj/distrib/amd64/ramdisk_cd/rdsshd
+           rmdir /usr/src/distrib/amd64/ramdisk_cd/rdsshd
+
+     prepare-unpatch-rdsshd prints the recorded object path before removing the
+     symlink.  /usr/obj is the default; use the printed path when different.
+     patch(1) removes added files but leaves their empty parent directory.
+     rmdir refuses non-empty directories.
+
+FILES
+     /usr/src/distrib/amd64/ramdisk_cd/rdsshd
+             Added source directory and Makefile.rdsshd entry point.
+
+     obj/bsdsshd.rd
+             Uncompressed enhanced ramdisk kernel.
+
+     obj/bsdsshd.gz
+             Stripped and compressed ramdisk kernel installed as bsdsshd.rd by
+             install-rdsshd and as /bsd in the enhanced miniroot.
+
+     obj/minirootXX_sshd.img
+             Enhanced BIOS- and EFI-bootable miniroot image.
+
+     obj/vnd
+             Private ownership record for an attached vnd during media
+             assembly.  Removed after successful assembly or safe cleanup.
+
+DIAGNOSTICS
+     private object directory is not active
+             Run make -f Makefile.rdsshd obj as a separate invocation before
+             building.
+
+     normal OpenBSD object root does not exist
+             Create BSDOBJDIR with normal OpenBSD ownership and permissions.
+
+     normal ramdisk_cd object directory is not active
+             The stock obj target did not select an object directory distinct
+             from its source directory.  Check BSDOBJDIR and rerun the build.
+
+     stock ramdisk_cd instbin was not built
+             The stock instbin target did not produce its expected output.
+             Inspect the preceding failure.  Clean ramdisk_cd before retrying
+             after a failed trace link.
+
+     invalid public key on line N of RDSSHD_AUTHORIZED_KEYS
+             Replace the indicated line with a public key accepted by
+             ssh-keygen(1).
+
+     RDSSHD_HOST_KEY is not an unencrypted Ed25519 private key
+             Supply an Ed25519 host private key without a passphrase.
+
+     stale private vnd state
+             Run unconfig-rdsshd before rebuilding the miniroot.
+
+     refusing to detach vnd not owned by rdsshd
+             The recorded device no longer covers the enhanced image.  The
+             target leaves it attached for manual inspection.
+
+SEE ALSO
+     make(1), patch(1), ssh(1), ssh-keygen(1), softraid(4), vnd(4), disktab(5),
+     install.site(5), sshd_config(5), autoinstall(8), bioctl(8), boot(8),
+     boot_amd64(8), config(8), installboot(8), makefs(8), rdsetroot(8),
+     release(8), sshd(8), vnconfig(8)
+
+BSDSSHD.RD                      July 18, 2026                       BSDSSHD.RD
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/console-lock.sshd.patch
@@ -0,0 +1,18 @@
+--- dot.profile.tmp
++++ dot.profile.tmp
+@@ -87,6 +87,15 @@
+ 	[[ -x /sbin/dhcpleased ]] && /sbin/dhcpleased 2>/dev/null
+ 	[[ -x /sbin/slaacd ]] && /sbin/slaacd 2>/dev/null
+ 
++	while [[ ! -f /var/run/rdsshd.ready ]]; do
++		/install -af /auto_install.conf
++		sleep 1
++	done
++
++	while :; do
++		sleep 3600
++	done
++
+ 	# Set up some sane tty defaults.
+ 	echo 'erase ^?, werase ^W, kill ^U, intr ^C, status ^T'
+ 	stty newcrt werase ^W intr ^C kill ^U erase ^? status ^T
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/init.sshd.patch
@@ -0,0 +1,20 @@
+--- init.c.tmp
++++ init.c.tmp
+@@ -517,7 +517,6 @@
+ 		/*
+ 		 * Start the single user session.
+ 		 */
+-		setctty(_PATH_CONSOLE);
+ 
+ #ifdef SECURE
+ 		/*
+@@ -651,8 +650,7 @@
+ 		}
+ 	}
+ 
+-	runcom_mode = FASTBOOT;
+-	return runcom;
++	return single_user;
+ }
+ 
+ /*
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/installer.sshd.patch
@@ -0,0 +1,71 @@
+--- install.sub.tmp
++++ install.sub.tmp
+@@ -1377,7 +1377,10 @@
+ 		ask_until "$_q (name, lladdr, '?', or 'done')" \
+ 		    ${_p:-$( (get_ifs netboot; get_ifs) | sed q )}
+ 
+-		[[ $resp == done ]] && break
++		if [[ $resp == done ]]; then
++			NIFS=$(ls -1 /tmp/i/hostname.* 2>/dev/null | grep -c ^)
++			break
++		fi
+ 		[[ $resp == '?'  ]] && continue
+ 
+ 		# Quote $resp to prevent user from confusing isin() by
+@@ -3170,6 +3173,21 @@
+ 	echo "\nConfiguring the root disk $ROOTDISK...\n"
+ }
+ 
++rdsshd_start() {
++	local _pid
++	if [[ -s /var/run/sshd.pid ]]; then
++		_pid=$(</var/run/sshd.pid)
++		[[ $_pid == +([0-9]) ]] && (( _pid > 1 )) && \
++		    kill -0 "$_pid" 2>/dev/null && return 0
++	fi
++	if [[ ! -s /etc/ssh/ssh_host_ed25519_key ]]; then
++		/usr/bin/ssh-keygen -q -t ed25519 -N "" \
++		    -f /etc/ssh/ssh_host_ed25519_key || return 1
++	fi
++	/usr/sbin/sshd -t -f /etc/ssh/sshd_config || return 1
++	/usr/sbin/sshd -f /etc/ssh/sshd_config || return 1
++}
++
+ do_install() {
+ 	local _rootkey _rootpass
+ 
+@@ -3190,7 +3208,20 @@
+ 
+ 	# Configure the network.
+ 	donetconfig
++	if $AI && [[ $AI_RESPFILE == /auto_install.conf ]]; then
++		start_cgiinfo
++		wait_cgiinfo
++		rdsshd_start || err_exit "Could not start ramdisk sshd."
++		>/var/run/rdsshd.ready
++		cat <<__EOT
+ 
++ramdisk sshd is running.
++Connect as root with the matching key.
++Run: install
++__EOT
++		exit 0
++	fi
++
+ 	# Fetch list of mirror servers and installer choices from previous runs.
+ 	start_cgiinfo
+ 
+--- dot.profile.tmp
++++ dot.profile.tmp
+@@ -130,6 +130,11 @@
+ 		if $timeout; then
+ 			timeout=false
+ 			echo
++			if [[ -f /auto_install.conf ]]; then
++				/install -af /auto_install.conf
++				[[ -f /var/run/rdsshd.ready ]] && break
++				continue
++			fi
+ 			REPLY=a
+ 		else
+ 			# User has made a choice; stop the read timeout.
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/list.sshd
@@ -0,0 +1,35 @@
+#	$OpenBSD$
+
+# bsdsshd.rd overlay.
+MKDIR	usr/libexec
+MKDIR	etc/ssh
+MKDIR	root
+MKDIR	root/.ssh
+
+COPY	${OBJDIR}/../stage/sshd_config			etc/ssh/sshd_config
+SPECIAL test ! -s ${OBJDIR}/../stage/ssh_host_ed25519_key || install -c -m 600 -o root -g wheel ${OBJDIR}/../stage/ssh_host_ed25519_key etc/ssh/ssh_host_ed25519_key
+COPY	${OBJDIR}/../stage/auto_install.conf		auto_install.conf
+SCRIPT	${OBJDIR}/../stage/dot.profile			.profile
+SCRIPT	${OBJDIR}/../stage/install.sub			install.sub
+SPECIAL	chmod 755 install.sub
+
+COPY	${OBJDIR}/../stage/master.passwd		etc/master.passwd
+COPY	${OBJDIR}/../stage/group			etc/group
+SPECIAL	pwd_mkdb -p -d etc master.passwd; rm etc/master.passwd
+
+COPY	${CURDIR}/rdsshd/root.profile			root/.profile
+COPY	${OBJDIR}/../stage/authorized_keys		root/.ssh/authorized_keys
+SPECIAL	chmod 700 root root/.ssh; chmod 600 root/.ssh/authorized_keys
+
+SPECIAL	cd dev; sh MAKEDEV pty0 ptm
+
+COPY	${OBJDIR}/../stage/bin/sshd			usr/sbin/sshd
+SPECIAL	chmod 511 usr/sbin/sshd
+COPY	${OBJDIR}/../stage/bin/ssh-keygen		usr/bin/ssh-keygen
+SPECIAL	chmod 555 usr/bin/ssh-keygen
+COPY	${OBJDIR}/../stage/bin/sshd-session		usr/libexec/sshd-session
+SPECIAL	chmod 511 usr/libexec/sshd-session
+COPY	${OBJDIR}/../stage/bin/sshd-auth		usr/libexec/sshd-auth
+SPECIAL	chmod 511 usr/libexec/sshd-auth
+
+COPY	${CURDIR}/../../../etc/etc.amd64/login.conf	etc/login.conf
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/root.profile
@@ -0,0 +1,16 @@
+set +o sh
+export VNAME=$(sysctl -n kern.osrelease)
+export VERSION="${VNAME%.*}${VNAME#*.}"
+export ARCH=$(sysctl -n hw.machine)
+export OBSD="OpenBSD/$ARCH $VNAME"
+PATH=/sbin:/bin:/usr/bin:/usr/sbin:/
+export PATH
+TERM=vt220
+export TERM
+umask 022
+set -o emacs
+PS1='rd# '
+export PS1
+echo
+echo "ramdisk sshd is running."
+echo "Run install to continue."
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/sshd_config
@@ -0,0 +1,14 @@
+Port ${RDSSHD_PORT}
+HostKey /etc/ssh/ssh_host_ed25519_key
+AllowUsers root
+PermitRootLogin prohibit-password
+AuthorizedKeysFile .ssh/authorized_keys
+PubkeyAuthentication yes
+AuthenticationMethods publickey
+PasswordAuthentication no
+KbdInteractiveAuthentication no
+Compression no
+PermitUserRC no
+PrintMotd no
+PrintLastLog no
+DisableForwarding yes
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/RAMDISK_CD_SSHD
@@ -0,0 +1,12 @@
+#	$OpenBSD$
+
+include "arch/amd64/conf/RAMDISK_CD"
+
+# Keep config(8)'s option append pointer valid while removing the tail.
+option		RDSSHD_RDROOT
+rmoption	NO_PROPOLICE
+rmoption	MINIROOTSIZE
+option		MINIROOTSIZE=40960
+rmoption	RDSSHD_RDROOT
+
+pseudo-device	pty	16
--- /dev/null
+++ b/distrib/amd64/ramdisk_cd/rdsshd/RAMDISK_CD_SSHD_NO_PROPOLICE
@@ -0,0 +1,11 @@
+#	$OpenBSD$
+
+include "arch/amd64/conf/RAMDISK_CD"
+
+# Keep config(8)'s option append pointer valid while removing the tail.
+option		RDSSHD_RDROOT
+rmoption	MINIROOTSIZE
+option		MINIROOTSIZE=40960
+rmoption	RDSSHD_RDROOT
+
+pseudo-device	pty	16

SYNOPSIS

Run obj separately. Supply a file containing at least one authorized public key.

Build invocationraw
cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
make -f Makefile.rdsshd obj
make -f Makefile.rdsshd rdsshd \
    RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub

SOURCE LAYOUT

The patch adds one source directory containing eleven files.

Added filesraw
distrib/amd64/ramdisk_cd/rdsshd/Makefile.rdsshd
distrib/amd64/ramdisk_cd/rdsshd/Makefile.init
distrib/amd64/ramdisk_cd/rdsshd/README.rdsshd
distrib/amd64/ramdisk_cd/rdsshd/console-lock.sshd.patch
distrib/amd64/ramdisk_cd/rdsshd/init.sshd.patch
distrib/amd64/ramdisk_cd/rdsshd/installer.sshd.patch
distrib/amd64/ramdisk_cd/rdsshd/list.sshd
distrib/amd64/ramdisk_cd/rdsshd/root.profile
distrib/amd64/ramdisk_cd/rdsshd/sshd_config
distrib/amd64/ramdisk_cd/rdsshd/RAMDISK_CD_SSHD
distrib/amd64/ramdisk_cd/rdsshd/RAMDISK_CD_SSHD_NO_PROPOLICE

TARGETS

rdsshd
Builds both enhanced images.
install-rdsshd
Defined when RELEASEDIR is set. Installs compressed bsdsshd.rd and minirootXX_sshd.img.
unconfig-rdsshd
Unmounts the private mount point and detaches its recorded vnd after an interrupted miniroot build.
clean-rdsshd, clean, cleandir
Remove private outputs. Normal OpenBSD clean targets retain ownership of stock instbin objects.
prepare-unpatch-rdsshd
Cleans private outputs, prints the wrapper object path, and removes the source obj symlink.
Install release filesraw
cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
make -f Makefile.rdsshd install-rdsshd \
    RELEASEDIR=/path/to/release
Makefile.rdsshdEnhanced wrapper and targetsrawโ†“
#	$OpenBSD$

.include <bsd.own.mk>

RDSSHD_AUTHORIZED_KEYS?=
RDSSHD_HOST_KEY?=
RDSSHD_CONSOLE_LOCK?=	no
RDSSHD_KERNEL_STACK_PROTECTOR?=	yes
RDSSHD_PORT?=	22
RDSSHD_AI_IF?=	em0
RDSSHD_AI_HOSTNAME?=	rdinstall
RDSSHD_AI_IPV4?=	autoconf
RDSSHD_AI_NETMASK?=	255.255.255.0
RDSSHD_AI_ROUTE?=	none
RDSSHD_AI_IPV6?=	none
RDSSHD_AI_DOMAIN?=	my.domain
RDSSHD_AI_DNS?=	none
RDSSHD_FSSIZE?=	32768

RDSSHD_BASEDIR=	${.CURDIR}/..
RDSSHD_TOP=	${.CURDIR}/../../../..
RDSSHD_UTILS=	${RDSSHD_BASEDIR}/../../miniroot
RDSSHD_MTREE=	${RDSSHD_UTILS}/mtree.conf
RDSSHD_EFIBOOT=	${DESTDIR}/usr/mdec/BOOTX64.EFI \
		${DESTDIR}/usr/mdec/BOOTIA32.EFI
RDSSHD_MOUNT_ARGS_MSDOS=	-o-s

RDSSHD_RAMDISK=	RAMDISK_CD_SSHD
RDSSHD_KERNEL=	${.OBJDIR}/bsd
.if ${RDSSHD_KERNEL_STACK_PROTECTOR:L} == "no"
RDSSHD_RAMDISK=	RAMDISK_CD_SSHD_NO_PROPOLICE
RDSSHD_KERNEL=	${.OBJDIR}/bsd.no-propolice
.endif
RDSSHD_FS=	miniroot${OSrev}_sshd.img
RDSSHD_BUILDOBJDIR=	${.OBJDIR}/build
RDSSHD_KERNELOBJDIR=	${RDSSHD_BUILDOBJDIR}/kernel/${RDSSHD_RAMDISK}
RDSSHD_INSTBIN=	${RDSSHD_BUILDOBJDIR}/instbin
RDSSHD_STAGE=	${.OBJDIR}/stage
RDSSHD_RDOBJDIR=	${.OBJDIR}/rdobj
RDSSHD_SSHOBJDIR=	${.OBJDIR}/sshobj
RDSSHD_INITOBJDIR=	${.OBJDIR}/initobj
RDSSHD_INITSRCDIR=	${.OBJDIR}/initsrc
RDSSHD_MOUNT_POINT=	${.OBJDIR}/mnt
RDSSHD_VND=	${.OBJDIR}/vnd
RDSSHD_BOOT=	${.OBJDIR}/boot
RDSSHD_OBJCHECK=	${.OBJDIR}/.rdsshd-obj-ok
RDSSHD_MAKEFILE=	${.CURDIR}/Makefile.rdsshd
RDSSHD_INITMAKEFILE=	${.CURDIR}/Makefile.init
RDSSHD_KERNELCONF=	${.CURDIR}/${RDSSHD_RAMDISK}
RDSSHD_BASECONF=	${RDSSHD_TOP}/sys/arch/${MACHINE}/conf/RAMDISK_CD
RDSSHD_KERNELCONFDEPS=	${RDSSHD_BASECONF}
RDSSHD_LISTS=	${RDSSHD_BASEDIR}/list ${.CURDIR}/list.sshd \
		${RDSSHD_STAGE}/list.console
RDSSHD_INITSRC=	${RDSSHD_INITSRCDIR}/init.c
RDSSHD_INIT=	${RDSSHD_STAGE}/init
RDSSHD_INSTALLERPATCH=	${.CURDIR}/installer.sshd.patch
RDSSHD_CONSOLEPATCH=	${.CURDIR}/console-lock.sshd.patch
RDSSHD_INITPATCH=	${.CURDIR}/init.sshd.patch
RDSSHD_SSHDCONF=	${.CURDIR}/sshd_config
RDSSHD_MRMAKEFSARGS?=	-s 20m \
		-o rdroot,minfree=0,bsize=4096,fsize=512,density=4096
RDSSHD_BINS=	${RDSSHD_STAGE}/bin/sshd \
		${RDSSHD_STAGE}/bin/ssh-keygen \
		${RDSSHD_STAGE}/bin/sshd-session \
		${RDSSHD_STAGE}/bin/sshd-auth

RDSSHD_INITDEP=
.if ${RDSSHD_CONSOLE_LOCK:L} == "yes"
RDSSHD_INITDEP=	${RDSSHD_INIT}
.endif

.PHONY: rdsshd
rdsshd: bsdsshd.rd ${RDSSHD_FS}

.PHONY: rdsshd-objcheck
rdsshd-objcheck: ${RDSSHD_OBJCHECK}

${RDSSHD_OBJCHECK}:
	@if [ "${.OBJDIR}" = "${.CURDIR}" ]; then \
		echo "private object directory is not active;" >&2; \
		echo "run 'make -f Makefile.rdsshd obj' separately first" >&2; \
		exit 1; \
	fi
	touch $@

${RDSSHD_KERNEL}: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE} \
	    ${RDSSHD_KERNELCONF} ${RDSSHD_KERNELCONFDEPS}
	install -d -o ${BUILDUSER} -g ${WOBJGROUP} ${RDSSHD_KERNELOBJDIR}
	su ${BUILDUSER} -c \
	    'config -b ${RDSSHD_KERNELOBJDIR} -s ${RDSSHD_TOP}/sys \
	    ${RDSSHD_KERNELCONF} && cd ${RDSSHD_KERNELOBJDIR} && \
	    MAKEOBJDIR=${RDSSHD_KERNELOBJDIR} ${MAKE} clean && \
	    exec env MAKEOBJDIR=${RDSSHD_KERNELOBJDIR} ${MAKE} ${MFLAGS}'
	cp -p ${RDSSHD_KERNELOBJDIR}/bsd $@

bsdsshd.gz: bsdsshd.rd
	objcopy -g -x -R .comment -R .SUNW_ctf \
	    -K rd_root_size -K rd_root_image \
	    bsdsshd.rd bsdsshd.strip
	gzip -9cn bsdsshd.strip > bsdsshd.gz
.if !empty(RDSSHD_HOST_KEY)
	chmod 600 bsdsshd.strip $@
.endif

.PHONY: rdsshd-stock-deps rdsshd-instbin
rdsshd-stock-deps: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
	@_objroot=`cd ${RDSSHD_BASEDIR} && ${MAKE} -V BSDOBJDIR`; \
	if [ ! -d "$$_objroot" ]; then \
		echo "normal OpenBSD object root does not exist: $$_objroot" >&2; \
		echo "create it before building rdsshd" >&2; \
		exit 1; \
	fi
	cd ${RDSSHD_TOP}/lib && ${MAKE} obj
	cd ${RDSSHD_TOP}/distrib/special && ${MAKE} obj
	cd ${RDSSHD_BASEDIR} && ${MAKE} obj
	@_srcdir=`cd ${RDSSHD_BASEDIR} && pwd`; \
	_objdir=`cd ${RDSSHD_BASEDIR} && ${MAKE} -V .OBJDIR`; \
	if [ "$$_objdir" = "$$_srcdir" ] || [ ! -d "$$_objdir" ]; then \
		echo "normal ramdisk_cd object directory is not active" >&2; \
		exit 1; \
	fi
	cd ${RDSSHD_TOP}/distrib/special/libstubs && ${MAKE} ${MFLAGS}

rdsshd-instbin: rdsshd-stock-deps
	cd ${RDSSHD_BASEDIR} && ${MAKE} ${MFLAGS} instbin
	install -d ${RDSSHD_BUILDOBJDIR}
	@_objdir=`cd ${RDSSHD_BASEDIR} && ${MAKE} -V .OBJDIR`; \
	if [ ! -f "$$_objdir/instbin" ]; then \
		echo "stock ramdisk_cd instbin was not built" >&2; \
		exit 1; \
	fi; \
	cp -p "$$_objdir/instbin" ${RDSSHD_INSTBIN}

bsdsshd.rd: rdsshd-files ${RDSSHD_INITDEP} rdsshd-instbin \
	    ${RDSSHD_KERNEL}
	install -d ${RDSSHD_RDOBJDIR}
	rm -f ${RDSSHD_RDOBJDIR}/instbin \
	    ${RDSSHD_RDOBJDIR}/mr.fs \
	    ${RDSSHD_RDOBJDIR}/bsd.rd
	cp -p ${RDSSHD_INSTBIN} ${RDSSHD_RDOBJDIR}/instbin
	rm -rf ${RDSSHD_RDOBJDIR}/mr.fs.d
	install -d -o root -g wheel ${RDSSHD_RDOBJDIR}/mr.fs.d
	mtree -def ${RDSSHD_MTREE} -p ${RDSSHD_RDOBJDIR}/mr.fs.d -u
	CURDIR=${RDSSHD_BASEDIR} OBJDIR=${RDSSHD_RDOBJDIR} OSrev=${OSrev} \
	    TARGDIR=${RDSSHD_RDOBJDIR}/mr.fs.d UTILS=${RDSSHD_UTILS} \
	    RELEASEDIR=${RELEASEDIR} sh ${RDSSHD_UTILS}/runlist.sh \
	    ${RDSSHD_LISTS}
	rm ${RDSSHD_RDOBJDIR}/mr.fs.d/instbin
	makefs ${RDSSHD_MRMAKEFSARGS} ${RDSSHD_RDOBJDIR}/mr.fs \
	    ${RDSSHD_RDOBJDIR}/mr.fs.d
	cp -p ${RDSSHD_KERNEL} ${RDSSHD_RDOBJDIR}/bsd.rd
	rdsetroot ${RDSSHD_RDOBJDIR}/bsd.rd ${RDSSHD_RDOBJDIR}/mr.fs
	cp ${RDSSHD_RDOBJDIR}/bsd.rd $@
.if !empty(RDSSHD_HOST_KEY)
	chmod 600 ${RDSSHD_RDOBJDIR}/mr.fs \
	    ${RDSSHD_RDOBJDIR}/bsd.rd $@
.endif

${RDSSHD_FS}: bsdsshd.gz
	-umount -f ${RDSSHD_MOUNT_POINT} >/dev/null 2>&1
	@if [ -e ${RDSSHD_VND} ] && [ ! -s ${RDSSHD_VND} ]; then \
		rm -f ${RDSSHD_VND}; \
	fi
	@if [ -e ${RDSSHD_VND} ]; then \
		echo "stale private vnd state; run" \
		    "'make -f Makefile.rdsshd unconfig-rdsshd' first" >&2; \
		exit 1; \
	fi
	install -d ${RDSSHD_MOUNT_POINT}
	dd if=/dev/zero of=${RDSSHD_FS} bs=512 count=${RDSSHD_FSSIZE}
	vnconfig -v ${.OBJDIR}/${RDSSHD_FS} > ${RDSSHD_VND}
	fdisk -yi -l ${RDSSHD_FSSIZE} -b 960 -f ${DESTDIR}/usr/mdec/mbr \
	    `cat ${RDSSHD_VND}`
	echo '/ *' | disklabel -wAT- `cat ${RDSSHD_VND}`
	newfs -t msdos /dev/r`cat ${RDSSHD_VND}`i
	mount ${RDSSHD_MOUNT_ARGS_MSDOS} /dev/`cat ${RDSSHD_VND}`i \
	    ${RDSSHD_MOUNT_POINT}
	mkdir -p ${RDSSHD_MOUNT_POINT}/efi/boot
	cp ${RDSSHD_EFIBOOT} ${RDSSHD_MOUNT_POINT}/efi/boot
	umount ${RDSSHD_MOUNT_POINT}
	newfs -O 1 -m 0 -o space -i 524288 -c ${RDSSHD_FSSIZE} \
	    /dev/r`cat ${RDSSHD_VND}`a
	mount /dev/`cat ${RDSSHD_VND}`a ${RDSSHD_MOUNT_POINT}
	objcopy -S -R .comment ${DESTDIR}/usr/mdec/boot ${RDSSHD_BOOT}
	installboot -v -r ${RDSSHD_MOUNT_POINT} `cat ${RDSSHD_VND}` \
	    ${DESTDIR}/usr/mdec/biosboot ${RDSSHD_BOOT}
	install -c -m 555 -o root -g wheel bsdsshd.gz \
	    ${RDSSHD_MOUNT_POINT}/bsd
	df -i ${RDSSHD_MOUNT_POINT}
	umount ${RDSSHD_MOUNT_POINT}
	vnconfig -u `cat ${RDSSHD_VND}`
	rm -f ${RDSSHD_VND}
.if !empty(RDSSHD_HOST_KEY)
	chmod 600 $@
.endif

.PHONY: rdsshd-check rdsshd-files rdsshd-scripts
rdsshd-check:
	@if [ -z "${RDSSHD_AUTHORIZED_KEYS}" ]; then \
		echo "set RDSSHD_AUTHORIZED_KEYS to a public key file" >&2; \
		exit 1; \
	fi
	@case "${RDSSHD_CONSOLE_LOCK:L}" in \
	yes|no) ;; \
	*) echo "RDSSHD_CONSOLE_LOCK must be yes or no" >&2; exit 1;; \
	esac
	@case "${RDSSHD_KERNEL_STACK_PROTECTOR:L}" in \
	yes|no) ;; \
	*) echo "RDSSHD_KERNEL_STACK_PROTECTOR must be yes or no" >&2; exit 1;; \
	esac

${RDSSHD_INITSRC}: ${RDSSHD_OBJCHECK} ${RDSSHD_TOP}/sbin/init/init.c \
	    ${RDSSHD_INITPATCH}
	install -d ${RDSSHD_INITSRCDIR}
	rm -f ${RDSSHD_INITSRC}.tmp ${RDSSHD_INITSRC}.tmp.orig \
	    ${RDSSHD_INITSRC}.tmp.rej
	cp ${RDSSHD_TOP}/sbin/init/init.c ${RDSSHD_INITSRC}.tmp
	cd ${RDSSHD_INITSRCDIR} && \
	    patch -f -s -p0 -F0 < ${RDSSHD_INITPATCH}
	mv ${RDSSHD_INITSRC}.tmp ${RDSSHD_INITSRC}

${RDSSHD_INIT}: ${RDSSHD_INITSRC} \
	    ${RDSSHD_TOP}/sbin/init/pathnames.h ${RDSSHD_INITMAKEFILE} \
	    ${RDSSHD_BASEDIR}/../../special/init/Makefile \
	    ${RDSSHD_BASEDIR}/../../special/Makefile.inc
	install -d ${RDSSHD_INITOBJDIR} ${RDSSHD_STAGE}
	cd ${RDSSHD_BASEDIR}/../../special/init && \
	    MAKEOBJDIR=${RDSSHD_INITOBJDIR} ${MAKE} ${MFLAGS} \
	    -f ${RDSSHD_INITMAKEFILE} \
	    RDSSHD_INITSRCDIR=${RDSSHD_INITSRCDIR} init
	install -c -s ${RDSSHD_INITOBJDIR}/init $@

rdsshd-scripts: ${RDSSHD_OBJCHECK} ${RDSSHD_UTILS}/install.sub \
	    ${RDSSHD_UTILS}/dot.profile \
	    ${RDSSHD_INSTALLERPATCH} ${RDSSHD_CONSOLEPATCH}
	install -d ${RDSSHD_STAGE}
	rm -f ${RDSSHD_STAGE}/install.sub.tmp \
	    ${RDSSHD_STAGE}/install.sub.tmp.orig \
	    ${RDSSHD_STAGE}/install.sub.tmp.rej \
	    ${RDSSHD_STAGE}/dot.profile.tmp \
	    ${RDSSHD_STAGE}/dot.profile.tmp.orig \
	    ${RDSSHD_STAGE}/dot.profile.tmp.rej
	cp ${RDSSHD_UTILS}/install.sub ${RDSSHD_STAGE}/install.sub.tmp
	cp ${RDSSHD_UTILS}/dot.profile ${RDSSHD_STAGE}/dot.profile.tmp
	cd ${RDSSHD_STAGE} && \
	    patch -f -s -p0 -F0 < ${RDSSHD_INSTALLERPATCH}
.if ${RDSSHD_CONSOLE_LOCK:L} == "yes"
	cd ${RDSSHD_STAGE} && \
	    patch -f -s -p0 -F0 < ${RDSSHD_CONSOLEPATCH}
.endif
	chmod 755 ${RDSSHD_STAGE}/install.sub.tmp
	mv ${RDSSHD_STAGE}/install.sub.tmp ${RDSSHD_STAGE}/install.sub
	mv ${RDSSHD_STAGE}/dot.profile.tmp ${RDSSHD_STAGE}/dot.profile

rdsshd-files: rdsshd-check rdsshd-scripts ${RDSSHD_BINS} \
	    ${RDSSHD_SSHDCONF}
	install -d ${RDSSHD_STAGE}
	@if [ "${RDSSHD_CONSOLE_LOCK:L}" = yes ]; then \
		echo 'COPY ${RDSSHD_INIT} sbin/init'; \
	fi > ${RDSSHD_STAGE}/list.console
	sed '/^root:/s|:/bin/ksh$$|:/bin/sh|' \
	    ${RDSSHD_UTILS}/master.passwd > ${RDSSHD_STAGE}/master.passwd
	grep '^root:.*:/bin/sh$$' ${RDSSHD_STAGE}/master.passwd >/dev/null
	sed -e '/^[	 ]*$$/d' -e '/^[	 ]*#/d' \
	    < "${RDSSHD_AUTHORIZED_KEYS}" > ${RDSSHD_STAGE}/authorized_keys
	test -s ${RDSSHD_STAGE}/authorized_keys
	@_n=0; while IFS= read -r _key; do \
		_n=$$((_n + 1)); \
		if ! printf '%s\n' "$$_key" | \
		    ${RDSSHD_STAGE}/bin/ssh-keygen -l -f - >/dev/null 2>&1; then \
			echo "invalid public key on line $$_n of RDSSHD_AUTHORIZED_KEYS" >&2; \
			exit 1; \
		fi; \
	done < ${RDSSHD_STAGE}/authorized_keys
	grep '^sshd:' ${RDSSHD_STAGE}/master.passwd >/dev/null || \
	    grep '^sshd:' ${RDSSHD_TOP}/etc/master.passwd >> \
	    ${RDSSHD_STAGE}/master.passwd
	cp ${RDSSHD_UTILS}/group ${RDSSHD_STAGE}/group
	grep '^sshd:' ${RDSSHD_STAGE}/group >/dev/null || \
	    grep '^sshd:' ${RDSSHD_TOP}/etc/group >> ${RDSSHD_STAGE}/group
	{ \
	    printf 'System hostname = %s\n' '${RDSSHD_AI_HOSTNAME}'; \
	    printf 'Network interface to configure = %s\n' '${RDSSHD_AI_IF}'; \
	    printf 'IPv4 address for %s = %s\n' \
		'${RDSSHD_AI_IF}' '${RDSSHD_AI_IPV4}'; \
	    case '${RDSSHD_AI_IPV4}' in \
	    none|autoconf|dhcp) ;; \
	    *) printf 'Netmask for %s = %s\n' \
		'${RDSSHD_AI_IF}' '${RDSSHD_AI_NETMASK}'; \
	       printf 'Default IPv4 route = %s\n' '${RDSSHD_AI_ROUTE}' ;; \
	    esac; \
	    printf 'IPv6 address for %s = %s\n' \
		'${RDSSHD_AI_IF}' '${RDSSHD_AI_IPV6}'; \
	    printf 'Network interface to configure = done\n'; \
	    printf 'DNS domain name = %s\n' '${RDSSHD_AI_DOMAIN}'; \
	    printf 'DNS nameservers = %s\n' '${RDSSHD_AI_DNS}'; \
	} > ${RDSSHD_STAGE}/auto_install.conf
	sed 's|^Port .*|Port ${RDSSHD_PORT}|' ${RDSSHD_SSHDCONF} > \
	    ${RDSSHD_STAGE}/sshd_config
	rm -f ${RDSSHD_STAGE}/ssh_host_ed25519_key \
	    ${RDSSHD_STAGE}/ssh_host_ed25519_key.pub \
	    ${RDSSHD_STAGE}/ssh_host_ed25519_key.test \
	    ${RDSSHD_STAGE}/ssh_host_ed25519_key.test.pub \
	    ${RDSSHD_STAGE}/sshd_config.test
	@if [ -n "${RDSSHD_HOST_KEY}" ]; then \
		if ! ${RDSSHD_STAGE}/bin/ssh-keygen -y -P '' \
		    -f "${RDSSHD_HOST_KEY}" 2>/dev/null | grep -q '^ssh-ed25519 '; then \
			echo "RDSSHD_HOST_KEY is not an unencrypted Ed25519 private key" >&2; \
			exit 1; \
		fi; \
		install -c -m 600 "${RDSSHD_HOST_KEY}" \
		    ${RDSSHD_STAGE}/ssh_host_ed25519_key; \
	fi
	@_key=${RDSSHD_STAGE}/ssh_host_ed25519_key; \
	_testkey=${RDSSHD_STAGE}/ssh_host_ed25519_key.test; \
	_testconf=${RDSSHD_STAGE}/sshd_config.test; \
	if [[ ! -s $$_key ]]; then \
		_key=$$_testkey; \
		${RDSSHD_STAGE}/bin/ssh-keygen -q -t ed25519 -N '' \
		    -f $$_key || exit 1; \
	fi; \
	sed "s|^HostKey .*|HostKey $$_key|" \
	    ${RDSSHD_STAGE}/sshd_config > $$_testconf; \
	${RDSSHD_STAGE}/bin/sshd -t -f $$_testconf; \
	_status=$$?; \
	rm -f $$_testconf $$_testkey $$_testkey.pub; \
	exit $$_status
${RDSSHD_STAGE}/bin/sshd: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
	install -d ${RDSSHD_STAGE}/bin ${RDSSHD_SSHOBJDIR}/sshd
	cd ${RDSSHD_TOP}/usr.bin/ssh/sshd && \
	    MAKEOBJDIR=${RDSSHD_SSHOBJDIR}/sshd ${MAKE} ${MFLAGS} \
	    LDSTATIC="${STATIC}" ZLIB=no
	install -c -s ${RDSSHD_SSHOBJDIR}/sshd/sshd $@

${RDSSHD_STAGE}/bin/ssh-keygen: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
	install -d ${RDSSHD_STAGE}/bin ${RDSSHD_SSHOBJDIR}/ssh-keygen
	cd ${RDSSHD_TOP}/usr.bin/ssh/ssh-keygen && \
	    MAKEOBJDIR=${RDSSHD_SSHOBJDIR}/ssh-keygen ${MAKE} ${MFLAGS} \
	    LDSTATIC="${STATIC}" ZLIB=no
	install -c -s ${RDSSHD_SSHOBJDIR}/ssh-keygen/ssh-keygen $@

${RDSSHD_STAGE}/bin/sshd-session: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
	install -d ${RDSSHD_STAGE}/bin ${RDSSHD_SSHOBJDIR}/sshd-session
	cd ${RDSSHD_TOP}/usr.bin/ssh/sshd-session && \
	    MAKEOBJDIR=${RDSSHD_SSHOBJDIR}/sshd-session ${MAKE} ${MFLAGS} \
	    LDSTATIC="${STATIC}" ZLIB=no
	install -c -s ${RDSSHD_SSHOBJDIR}/sshd-session/sshd-session $@

${RDSSHD_STAGE}/bin/sshd-auth: ${RDSSHD_OBJCHECK} ${RDSSHD_MAKEFILE}
	install -d ${RDSSHD_STAGE}/bin ${RDSSHD_SSHOBJDIR}/sshd-auth
	cd ${RDSSHD_TOP}/usr.bin/ssh/sshd-auth && \
	    MAKEOBJDIR=${RDSSHD_SSHOBJDIR}/sshd-auth ${MAKE} ${MFLAGS} \
	    LDSTATIC="${STATIC}" ZLIB=no
	install -c -s ${RDSSHD_SSHOBJDIR}/sshd-auth/sshd-auth $@

.PHONY: unconfig-rdsshd
unconfig-rdsshd:
	-umount -f ${RDSSHD_MOUNT_POINT} >/dev/null 2>&1
	@if [ -e ${RDSSHD_VND} ] && [ ! -s ${RDSSHD_VND} ]; then \
		rm -f ${RDSSHD_VND}; \
	elif [ -f ${RDSSHD_VND} ]; then \
		_vnd=`cat ${RDSSHD_VND}`; \
		_unit=$${_vnd#vnd}; \
		if [ "vnd$$_unit" != "$$_vnd" ] || [ -z "$$_unit" ]; then \
			echo "invalid private vnd state: $$_vnd" >&2; exit 1; \
		fi; \
		case "$$_unit" in \
		*[!0-9]*) echo "invalid private vnd state: $$_vnd" >&2; exit 1;; \
		esac; \
		_info=`vnconfig -l "$$_vnd"` || exit 1; \
		case "$$_info" in \
		"$$_vnd: not in use") rm -f ${RDSSHD_VND} ;; \
		"$$_vnd: covering ${.OBJDIR}/${RDSSHD_FS} on "*) \
			vnconfig -u "$$_vnd" && rm -f ${RDSSHD_VND} ;; \
		*) echo "refusing to detach vnd not owned by rdsshd: $$_info" >&2; \
			exit 1 ;; \
		esac; \
	fi

.ifdef RELEASEDIR
.PHONY: install-rdsshd
install-rdsshd: bsdsshd.gz ${RDSSHD_FS}
	cp bsdsshd.gz ${RELEASEDIR}/bsdsshd.rd
	cp ${RDSSHD_FS} ${RELEASEDIR}
.if empty(RDSSHD_HOST_KEY)
	chmod a+r ${RELEASEDIR}/bsdsshd.rd
.else
	chmod 600 ${RELEASEDIR}/bsdsshd.rd ${RELEASEDIR}/${RDSSHD_FS}
.endif
.endif

.PHONY: clean-rdsshd clean cleandir
clean-rdsshd: unconfig-rdsshd
	rm -f bsdsshd.rd bsdsshd.gz bsdsshd.strip ${RDSSHD_FS} \
	    ${.OBJDIR}/bsd ${.OBJDIR}/bsd.no-propolice ${RDSSHD_BOOT} \
	    ${RDSSHD_OBJCHECK}
	rm -rf ${RDSSHD_BUILDOBJDIR} ${RDSSHD_STAGE} ${RDSSHD_RDOBJDIR} \
	    ${RDSSHD_SSHOBJDIR} ${RDSSHD_INITOBJDIR} ${RDSSHD_INITSRCDIR}
	-rmdir ${RDSSHD_MOUNT_POINT}

clean cleandir: clean-rdsshd

.PHONY: prepare-unpatch-rdsshd
prepare-unpatch-rdsshd: clean-rdsshd
	@if [ -L ${.CURDIR}/obj ]; then \
		_obj=`readlink ${.CURDIR}/obj`; \
		echo "empty private object directory may be removed: $$_obj"; \
		rm -f ${.CURDIR}/obj; \
	fi

.include <bsd.obj.mk>

BUILD MODEL

Private objects

Object ownershipraw
Private rdsshd paths:

obj/build/kernel       enhanced kernel objects
obj/build/instbin      copy of stock instbin
obj/sshobj             static OpenSSH objects
obj/initsrc            optional patched init source
obj/initobj            optional private init objects
obj/stage              generated and overlay files
obj/rdobj              ramdisk assembly
obj/bsd*               copied enhanced kernels
obj/boot               miniroot boot file
obj/mnt                miniroot mount point
obj/vnd                vnd ownership record

Normal OpenBSD paths used by stock instbin:

${BSDOBJDIR}/distrib/amd64/ramdisk_cd
${BSDOBJDIR}/distrib/special
${BSDOBJDIR}/lib

The enhanced kernel, OpenSSH programs, optional init, staging tree, ramdisk, media, mount point, and vnd record remain private. The kernel uses config(8) -b. OpenSSH uses explicit private MAKEOBJDIR paths. No directory below sys/arch/amd64/compile is added or used.

Stock instbin

Installer instbin is unmodified. The wrapper runs the normal obj targets for lib, distrib/special, and ramdisk_cd. It builds stock distrib/special/libstubs, invokes the stock instbin target, then copies the result to obj/build/instbin below the private object directory.

Component objects, reduced libraries, and crunchgen output retain their normal paths below ${BSDOBJDIR}. The wrapper does not rewrite generated crunchgen files. Normal OpenBSD clean targets own these stock objects. clean-rdsshd owns the private copy and enhanced outputs only. Enhanced and normal builds or cleans must not run concurrently when they share instbin objects.

No parent Makefile or SUBDIR list is changed. Normal top-level builds and cleans do not enter the rdsshd source directory.

Object checks and media state

BSDOBJDIR must exist with normal OpenBSD ownership and permissions. The default is /usr/obj, owned by build:wobj with mode 770. The build rejects source-directory fallback for both the wrapper and ramdisk_cd.

Normal and enhanced media use distinct boot files, image names, mount points, and vnd state. Cleanup detaches only the recorded vnd covering the absolute enhanced image path. A missing, malformed, or reused vnd is reported and retained.

KERNEL

RAMDISK_CD_SSHD includes stock RAMDISK_CD. It changes only the inherited stack-protection option, rdroot reservation, and pty count.

RAMDISK_CD_SSHDraw
#	$OpenBSD$

include "arch/amd64/conf/RAMDISK_CD"

# Keep config(8)'s option append pointer valid while removing the tail.
option		RDSSHD_RDROOT
rmoption	NO_PROPOLICE
rmoption	MINIROOTSIZE
option		MINIROOTSIZE=40960
rmoption	RDSSHD_RDROOT

pseudo-device	pty	16

Removing NO_PROPOLICE enables normal kernel stack protection. RDSSHD_KERNEL_STACK_PROTECTOR=no selects a separate overlay that retains NO_PROPOLICE.

RAMDISK_CD_SSHD_NO_PROPOLICEraw
#	$OpenBSD$

include "arch/amd64/conf/RAMDISK_CD"

# Keep config(8)'s option append pointer valid while removing the tail.
option		RDSSHD_RDROOT
rmoption	MINIROOTSIZE
option		MINIROOTSIZE=40960
rmoption	RDSSHD_RDROOT

pseudo-device	pty	16

MINIROOTSIZE=40960 reserves a 20 MiB rdroot. Sixteen ptys support ssh sessions. SMALL_KERNEL remains enabled.

The temporary RDSSHD_RDROOT option protects config(8)'s option-list append pointer while inherited tail entries are removed. It is absent from the final configuration.

RAMDISK

Embedded filesystem

Stock bsd.rd uses the host disktab entry rdrootb.

Stock rdrootraw
MRMAKEFSARGS=-o disklabel=rdrootb,minfree=0,density=4096

The enhanced target gives makefs(8) an explicit 20 MiB layout. It does not read or modify /etc/disktab.

Enhanced rdrootraw
RDSSHD_MRMAKEFSARGS?=	-s 20m \
		-o rdroot,minfree=0,bsize=4096,fsize=512,density=4096

Programs and files

Stock installer programs remain in instbin. OpenSSH is built through its normal Makefiles as separate static PIE executables with zlib disabled. The ssh(1) client is not included.

The separate executables are /usr/sbin/sshd, /usr/bin/ssh-keygen, /usr/libexec/sshd-session, and /usr/libexec/sshd-auth.

Ramdisk additionsrawโ†“
#	$OpenBSD$

# bsdsshd.rd overlay.
MKDIR	usr/libexec
MKDIR	etc/ssh
MKDIR	root
MKDIR	root/.ssh

COPY	${OBJDIR}/../stage/sshd_config			etc/ssh/sshd_config
SPECIAL test ! -s ${OBJDIR}/../stage/ssh_host_ed25519_key || install -c -m 600 -o root -g wheel ${OBJDIR}/../stage/ssh_host_ed25519_key etc/ssh/ssh_host_ed25519_key
COPY	${OBJDIR}/../stage/auto_install.conf		auto_install.conf
SCRIPT	${OBJDIR}/../stage/dot.profile			.profile
SCRIPT	${OBJDIR}/../stage/install.sub			install.sub
SPECIAL	chmod 755 install.sub

COPY	${OBJDIR}/../stage/master.passwd		etc/master.passwd
COPY	${OBJDIR}/../stage/group			etc/group
SPECIAL	pwd_mkdb -p -d etc master.passwd; rm etc/master.passwd

COPY	${CURDIR}/rdsshd/root.profile			root/.profile
COPY	${OBJDIR}/../stage/authorized_keys		root/.ssh/authorized_keys
SPECIAL	chmod 700 root root/.ssh; chmod 600 root/.ssh/authorized_keys

SPECIAL	cd dev; sh MAKEDEV pty0 ptm

COPY	${OBJDIR}/../stage/bin/sshd			usr/sbin/sshd
SPECIAL	chmod 511 usr/sbin/sshd
COPY	${OBJDIR}/../stage/bin/ssh-keygen		usr/bin/ssh-keygen
SPECIAL	chmod 555 usr/bin/ssh-keygen
COPY	${OBJDIR}/../stage/bin/sshd-session		usr/libexec/sshd-session
SPECIAL	chmod 511 usr/libexec/sshd-session
COPY	${OBJDIR}/../stage/bin/sshd-auth		usr/libexec/sshd-auth
SPECIAL	chmod 511 usr/libexec/sshd-auth

COPY	${CURDIR}/../../../etc/etc.amd64/login.conf	etc/login.conf

The overlay adds sshd configuration, authorized keys, the root profile, login classes, early network answers, the sshd account, and pty devices. Root retains stock instbin's -sh argv link. The root profile leaves sh mode, sets the installer environment, and selects TERM=vt220. The sshd Port directive is substituted only in private staging.

Root profileraw
set +o sh
export VNAME=$(sysctl -n kern.osrelease)
export VERSION="${VNAME%.*}${VNAME#*.}"
export ARCH=$(sysctl -n hw.machine)
export OBSD="OpenBSD/$ARCH $VNAME"
PATH=/sbin:/bin:/usr/bin:/usr/sbin:/
export PATH
TERM=vt220
export TERM
umask 022
set -o emacs
PS1='rd# '
export PS1
echo
echo "ramdisk sshd is running."
echo "Run install to continue."

Overlay patches

Each patch applies only to a private copy of a stock file. patch(1) runs non-interactively with zero fuzz. Context drift fails before replacement.

Installer integrationrawโ†“
--- install.sub.tmp
+++ install.sub.tmp
@@ -1377,7 +1377,10 @@
 		ask_until "$_q (name, lladdr, '?', or 'done')" \
 		    ${_p:-$( (get_ifs netboot; get_ifs) | sed q )}
 
-		[[ $resp == done ]] && break
+		if [[ $resp == done ]]; then
+			NIFS=$(ls -1 /tmp/i/hostname.* 2>/dev/null | grep -c ^)
+			break
+		fi
 		[[ $resp == '?'  ]] && continue
 
 		# Quote $resp to prevent user from confusing isin() by
@@ -3170,6 +3173,21 @@
 	echo "\nConfiguring the root disk $ROOTDISK...\n"
 }
 
+rdsshd_start() {
+	local _pid
+	if [[ -s /var/run/sshd.pid ]]; then
+		_pid=$(</var/run/sshd.pid)
+		[[ $_pid == +([0-9]) ]] && (( _pid > 1 )) && \
+		    kill -0 "$_pid" 2>/dev/null && return 0
+	fi
+	if [[ ! -s /etc/ssh/ssh_host_ed25519_key ]]; then
+		/usr/bin/ssh-keygen -q -t ed25519 -N "" \
+		    -f /etc/ssh/ssh_host_ed25519_key || return 1
+	fi
+	/usr/sbin/sshd -t -f /etc/ssh/sshd_config || return 1
+	/usr/sbin/sshd -f /etc/ssh/sshd_config || return 1
+}
+
 do_install() {
 	local _rootkey _rootpass
 
@@ -3190,7 +3208,20 @@
 
 	# Configure the network.
 	donetconfig
+	if $AI && [[ $AI_RESPFILE == /auto_install.conf ]]; then
+		start_cgiinfo
+		wait_cgiinfo
+		rdsshd_start || err_exit "Could not start ramdisk sshd."
+		>/var/run/rdsshd.ready
+		cat <<__EOT
 
+ramdisk sshd is running.
+Connect as root with the matching key.
+Run: install
+__EOT
+		exit 0
+	fi
+
 	# Fetch list of mirror servers and installer choices from previous runs.
 	start_cgiinfo
 
--- dot.profile.tmp
+++ dot.profile.tmp
@@ -130,6 +130,11 @@
 		if $timeout; then
 			timeout=false
 			echo
+			if [[ -f /auto_install.conf ]]; then
+				/install -af /auto_install.conf
+				[[ -f /var/run/rdsshd.ready ]] && break
+				continue
+			fi
 			REPLY=a
 		else
 			# User has made a choice; stop the read timeout.
Locked profile pathraw
--- dot.profile.tmp
+++ dot.profile.tmp
@@ -87,6 +87,15 @@
 	[[ -x /sbin/dhcpleased ]] && /sbin/dhcpleased 2>/dev/null
 	[[ -x /sbin/slaacd ]] && /sbin/slaacd 2>/dev/null
 
+	while [[ ! -f /var/run/rdsshd.ready ]]; do
+		/install -af /auto_install.conf
+		sleep 1
+	done
+
+	while :; do
+		sleep 3600
+	done
+
 	# Set up some sane tty defaults.
 	echo 'erase ^?, werase ^W, kill ^U, intr ^C, status ^T'
 	stty newcrt werase ^W intr ^C kill ^U erase ^? status ^T
Private init behaviorraw
--- init.c.tmp
+++ init.c.tmp
@@ -517,7 +517,6 @@
 		/*
 		 * Start the single user session.
 		 */
-		setctty(_PATH_CONSOLE);
 
 #ifdef SECURE
 		/*
@@ -651,8 +650,7 @@
 		}
 	}
 
-	runcom_mode = FASTBOOT;
-	return runcom;
+	return single_user;
 }
 
 /*

Miniroot media

The outer image follows the stock amd64 BIOS and EFI miniroot layout. It uses private boot, mount, image, and vnd paths. The default size is 32768 512-byte blocks, or 16 MiB.

BIOS and EFI media assemblyraw
	dd if=/dev/zero of=${RDSSHD_FS} bs=512 count=${RDSSHD_FSSIZE}
	vnconfig -v ${.OBJDIR}/${RDSSHD_FS} > ${RDSSHD_VND}
	fdisk -yi -l ${RDSSHD_FSSIZE} -b 960 -f ${DESTDIR}/usr/mdec/mbr \
	    `cat ${RDSSHD_VND}`
	echo '/ *' | disklabel -wAT- `cat ${RDSSHD_VND}`
	newfs -t msdos /dev/r`cat ${RDSSHD_VND}`i
	mount ${RDSSHD_MOUNT_ARGS_MSDOS} /dev/`cat ${RDSSHD_VND}`i \
	    ${RDSSHD_MOUNT_POINT}
	mkdir -p ${RDSSHD_MOUNT_POINT}/efi/boot
	cp ${RDSSHD_EFIBOOT} ${RDSSHD_MOUNT_POINT}/efi/boot
	umount ${RDSSHD_MOUNT_POINT}
	newfs -O 1 -m 0 -o space -i 524288 -c ${RDSSHD_FSSIZE} \
	    /dev/r`cat ${RDSSHD_VND}`a
	mount /dev/`cat ${RDSSHD_VND}`a ${RDSSHD_MOUNT_POINT}
	objcopy -S -R .comment ${DESTDIR}/usr/mdec/boot ${RDSSHD_BOOT}
	installboot -v -r ${RDSSHD_MOUNT_POINT} `cat ${RDSSHD_VND}` \
	    ${DESTDIR}/usr/mdec/biosboot ${RDSSHD_BOOT}
	install -c -m 555 -o root -g wheel bsdsshd.gz \
	    ${RDSSHD_MOUNT_POINT}/bsd

The miniroot stores compressed bsdsshd.gz as /bsd. It does not store uncompressed bsdsshd.rd.

Observed sizes

These amd64 sizes are observations from a default build, not fixed limits. Console locking may increase the compressed kernel size.

Example artifactsraw
boot                    87 KiB
bsdsshd.gz             9.0 MiB
bsdsshd.rd            27.2 MiB
bsdsshd.strip         26.7 MiB
minirootXX_sshd.img   16.0 MiB

SSHD

Authentication policy

sshd permits root public-key authentication only. Forwarding, user rc, interactive authentication, passwords, and compression are disabled. Static PIE executables retain normal OpenBSD OpenSSH compiler and linker protections.

sshd_configraw
Port ${RDSSHD_PORT}
HostKey /etc/ssh/ssh_host_ed25519_key
AllowUsers root
PermitRootLogin prohibit-password
AuthorizedKeysFile .ssh/authorized_keys
PubkeyAuthentication yes
AuthenticationMethods publickey
PasswordAuthentication no
KbdInteractiveAuthentication no
Compression no
PermitUserRC no
PrintMotd no
PrintLastLog no
DisableForwarding yes

Authorized keys

RDSSHD_AUTHORIZED_KEYS is mandatory. It may contain one or more public keys. Blank and comment lines are removed. Every retained line must pass ssh-keygen(1) public-key validation.

The installed file has mode 0600. /root and /root/.ssh have mode 0700.

authorized_keys validationraw
	sed -e '/^[	 ]*$$/d' -e '/^[	 ]*#/d' \
	    < "${RDSSHD_AUTHORIZED_KEYS}" > ${RDSSHD_STAGE}/authorized_keys
	test -s ${RDSSHD_STAGE}/authorized_keys
	@_n=0; while IFS= read -r _key; do \
		_n=$$((_n + 1)); \
		if ! printf '%s\n' "$$_key" | \
		    ${RDSSHD_STAGE}/bin/ssh-keygen -l -f - >/dev/null 2>&1; then \
			echo "invalid public key on line $$_n of RDSSHD_AUTHORIZED_KEYS" >&2; \
			exit 1; \
		fi; \
	done < ${RDSSHD_STAGE}/authorized_keys

Build-time configuration test

If RDSSHD_HOST_KEY is empty, the build generates obj/stage/ssh_host_ed25519_key.test and its public key. These files exist only to give the newly built sshd -t a host key while it validates the generated configuration.

The build removes the test configuration and key pair after validation. They never enter the ramdisk. An interrupted build may leave them below obj/stage; the next staging pass or cleandir removes them.

Host-key and sshd_config validationrawโ†“
	rm -f ${RDSSHD_STAGE}/ssh_host_ed25519_key \
	    ${RDSSHD_STAGE}/ssh_host_ed25519_key.pub \
	    ${RDSSHD_STAGE}/ssh_host_ed25519_key.test \
	    ${RDSSHD_STAGE}/ssh_host_ed25519_key.test.pub \
	    ${RDSSHD_STAGE}/sshd_config.test
	@if [ -n "${RDSSHD_HOST_KEY}" ]; then \
		if ! ${RDSSHD_STAGE}/bin/ssh-keygen -y -P '' \
		    -f "${RDSSHD_HOST_KEY}" 2>/dev/null | grep -q '^ssh-ed25519 '; then \
			echo "RDSSHD_HOST_KEY is not an unencrypted Ed25519 private key" >&2; \
			exit 1; \
		fi; \
		install -c -m 600 "${RDSSHD_HOST_KEY}" \
		    ${RDSSHD_STAGE}/ssh_host_ed25519_key; \
	fi
	@_key=${RDSSHD_STAGE}/ssh_host_ed25519_key; \
	_testkey=${RDSSHD_STAGE}/ssh_host_ed25519_key.test; \
	_testconf=${RDSSHD_STAGE}/sshd_config.test; \
	if [[ ! -s $$_key ]]; then \
		_key=$$_testkey; \
		${RDSSHD_STAGE}/bin/ssh-keygen -q -t ed25519 -N '' \
		    -f $$_key || exit 1; \
	fi; \
	sed "s|^HostKey .*|HostKey $$_key|" \
	    ${RDSSHD_STAGE}/sshd_config > $$_testconf; \
	${RDSSHD_STAGE}/bin/sshd -t -f $$_testconf; \
	_status=$$?; \
	rm -f $$_testconf $$_testkey $$_testkey.pub; \
	exit $$_status
Host-key and sshd_config validation1.1 KiBrawโ†‘

Boot-generated host key

No host key is embedded by default. First boot generates /etc/ssh/ssh_host_ed25519_key. Normal installer CGI fetches call feed_random before generation. An unreachable fetch delays sshd until the normal CGI timeout.

Embedded host key

RDSSHD_HOST_KEY may name an Ed25519 host private key without a passphrase. The build validates it and copies it to private staging with mode 0600. The newly built sshd validates the generated configuration against that staged key. Build-host ssh configuration and keys are not read or modified.

An embedded key is recoverable from private objects, kernels, images, and release copies. Key-bearing kernel and media artifacts use mode 0600.

Startup

sshd starts after donetconfig. A live numeric PID greater than one in /var/run/sshd.pid suppresses restart. The ramdisk has no process-inspection utility, so this checks liveness only.

install.sub creates /var/run/rdsshd.ready only after sshd starts. Profiles use this marker because installer exit status does not establish readiness.

sshd startuprawโ†“
rdsshd_start() {
	local _pid
	if [[ -s /var/run/sshd.pid ]]; then
		_pid=$(</var/run/sshd.pid)
		[[ $_pid == +([0-9]) ]] && (( _pid > 1 )) && \
		    kill -0 "$_pid" 2>/dev/null && return 0
	fi
	if [[ ! -s /etc/ssh/ssh_host_ed25519_key ]]; then
		/usr/bin/ssh-keygen -q -t ed25519 -N "" \
		    -f /etc/ssh/ssh_host_ed25519_key || return 1
	fi
	/usr/sbin/sshd -t -f /etc/ssh/sshd_config || return 1
	/usr/sbin/sshd -f /etc/ssh/sshd_config || return 1
}

donetconfig
if $AI && [[ $AI_RESPFILE == /auto_install.conf ]]; then
	start_cgiinfo
	wait_cgiinfo
	rdsshd_start || err_exit "Could not start ramdisk sshd."
	>/var/run/rdsshd.ready
	cat <<__EOT

ramdisk sshd is running.
Connect as root with the matching key.
Run: install
__EOT
	exit 0
fi

CONSOLE AND INSTALLER

Network bootstrap

The initial profile invokes install -af with early network answers. Static IPv4 configuration includes a netmask and default route. autoconf, dhcp, and none omit them.

Static IPv4 response fileDocumentation addresses use RFC 5737 spaceraw
System hostname = rdinstall
Network interface to configure = em0
IPv4 address for em0 = 192.0.2.10
Netmask for em0 = 255.255.255.0
Default IPv4 route = 192.0.2.1
IPv6 address for em0 = none
Network interface to configure = done
DNS domain name = example.com
DNS nameservers = 192.0.2.53

Default console

Automatic setup starts after the normal installer-menu timeout unless a local operator selects another action. The profile returns to the local ramdisk shell after sshd starts.

Default profile pathraw
if [[ -f /auto_install.conf ]]; then
	/install -af /auto_install.conf
	[[ -f /var/run/rdsshd.ready ]] && break
	continue
fi

Locked console

With RDSSHD_CONSOLE_LOCK=yes, a private static init replaces /sbin/init only in the enhanced ramdisk. It is built through the stock distrib/special/init Makefile using the dedicated wrapper and stock pathnames.h. Patched source and objects stay private.

Private init wrapperraw
#	$OpenBSD$

.PATH: ${RDSSHD_INITSRCDIR}
CPPFLAGS+=	-I${.CURDIR}/../../../sbin/init
.include "${.CURDIR}/Makefile"

The bootstrap child retains inherited /dev/null descriptors. It never acquires /dev/console as a controlling terminal. The profile branches before terminal setup, retries setup until the ready marker exists, then sleeps. It presents no menu or shell. Init restarts it after exit. The default uses stock init from instbin.

Locked profile pathraw
while [[ ! -f /var/run/rdsshd.ready ]]; do
	/install -af /auto_install.conf
	sleep 1
done

while :; do
	sleep 3600
done

Remote installer

After ssh login, run install. Complete storage preparation first. At network prompts, select done to retain the active configuration. Reconfiguration can drop the ssh session.

CAVEATS

Console locking removes the interactive local installer userland. The boot-loader and kernel consoles remain active. A local operator can still change early boot state, reset, halt, or deny remote access.

RDSSHD_KERNEL_STACK_PROTECTOR=no deliberately retains NO_PROPOLICE. This may reduce image size and weakens mitigation of kernel stack corruption.

Protect an embedded host key, the object tree, every key-bearing image, and installed copies. Use a distinct host key for each machine identity.

RDSSHD_FSSIZE is not auto-sized. A value too small for the compressed kernel and boot files causes miniroot assembly to fail.

Network variables become installer response-file answers. The build does not probe the interface or validate reachability. Incorrect values can prevent ssh access. A locked image then has no local installer userland with which to repair the configuration.

BUILD

Prerequisites and base build

Build on OpenBSD/amd64 with src.tar.gz and sys.tar.gz matching the installed OpenBSD release. Normal source-build prerequisites apply. The media target requires root for vnd, mount, device, and ownership operations. Kernel compilation runs as BUILDUSER.

Apply the patch below /usr/src. The configured BSDOBJDIR root, normally /usr/obj, must already exist.

Base buildraw
cd /usr/src
patch -p1 < /root/bsdsshd.rd.patch

cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
make -f Makefile.rdsshd obj
make -f Makefile.rdsshd rdsshd \
    RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub

Run obj separately. OpenBSD make selects .OBJDIR at startup and rejects a combined obj rdsshd invocation. No top-level /usr/src make obj is required. The wrapper creates only the normal object links required by stock instbin. Custom components retain private paths.

Static network

Supply netmask and route with a static IPv4 address. The documentation addresses below use the reserved 192.0.2.0/24 range.

Static IPv4 buildraw
cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
make -f Makefile.rdsshd rdsshd \
    RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub \
    RDSSHD_AI_IF=em0 \
    RDSSHD_AI_HOSTNAME=rdinstall \
    RDSSHD_AI_IPV4=192.0.2.10 \
    RDSSHD_AI_NETMASK=255.255.255.0 \
    RDSSHD_AI_ROUTE=192.0.2.1 \
    RDSSHD_AI_IPV6=none \
    RDSSHD_AI_DOMAIN=example.com \
    RDSSHD_AI_DNS=192.0.2.53

Console lock

Remove local installer sessionraw
cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
make -f Makefile.rdsshd rdsshd \
    RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub \
    RDSSHD_CONSOLE_LOCK=yes

Kernel without stack protection

Retain NO_PROPOLICEraw
cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
make -f Makefile.rdsshd rdsshd \
    RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub \
    RDSSHD_KERNEL_STACK_PROTECTOR=no

Embedded host key

Generate and embed host keyraw
ssh-keygen -q -t ed25519 -N '' -f /root/rdsshd_host_ed25519_key
cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
make -f Makefile.rdsshd rdsshd \
    RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub \
    RDSSHD_HOST_KEY=/root/rdsshd_host_ed25519_key

Use a distinct host key per machine identity. Protect its source, object tree, and all resulting images. Derive its public-key fingerprint before deployment and compare it when establishing host trust.

Host-key fingerprintraw
ssh-keygen -y -f /root/rdsshd_host_ed25519_key |
    ssh-keygen -lf -

Outputs

Primary build outputsraw
/usr/src/distrib/amd64/ramdisk_cd/rdsshd/obj/bsdsshd.rd
/usr/src/distrib/amd64/ramdisk_cd/rdsshd/obj/minirootXX_sshd.img

REBUILD AND CLEAN

Variable or key changes

Repeat rdsshd. Normal dependency rules reuse current stock instbin components, OpenSSH, private init, and the selected kernel. Staging, ramdisk, and media are regenerated.

Rebuild variables or keysraw
cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
make -f Makefile.rdsshd rdsshd \
    RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub

Source or toolchain changes

Clean both ownership domains after source, compiler, flag, or Makefile changes. A top-level cleandir may replace the three stock cleans. The next enhanced build recreates required normal object directories. The wrapper clean never removes stock objects.

Rebuild changed sourcesraw
cd /usr/src/distrib/amd64/ramdisk_cd
make cleandir
cd /usr/src/distrib/special
make cleandir
cd /usr/src/lib
make cleandir

cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
make -f Makefile.rdsshd cleandir
make -f Makefile.rdsshd obj
make -f Makefile.rdsshd rdsshd \
    RDSSHD_AUTHORIZED_KEYS=/root/id_ed25519.pub

Clean ramdisk_cd before retrying. Its instbin.map and reduced archives may be incomplete.

Clean stock instbin stateraw
cd /usr/src/distrib/amd64/ramdisk_cd
make cleandir

Interrupted media build

Release only the private mount and the vnd recorded as covering the enhanced image.

Release private media stateraw
cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
make -f Makefile.rdsshd unconfig-rdsshd

VARIABLES

RDSSHD_AUTHORIZED_KEYS
Required file containing one or more root authorized public keys.
RDSSHD_HOST_KEY
Optional Ed25519 host private key without a passphrase. An empty value generates a key at each boot. Default: empty.
RDSSHD_CONSOLE_LOCK
yes removes the local installer session. no retains the menu and shell. Default: no.
RDSSHD_KERNEL_STACK_PROTECTOR
yes removes inherited NO_PROPOLICE. no retains it. Default: yes.
RDSSHD_PORT
sshd port. The generated configuration must pass sshd -t. Default: 22.
RDSSHD_AI_IF
Network interface. Default: em0.
RDSSHD_AI_HOSTNAME
Hostname. Default: rdinstall.
RDSSHD_AI_IPV4
IPv4 address or installer keyword. Default: autoconf.
RDSSHD_AI_NETMASK
Static IPv4 netmask. Default: 255.255.255.0.
RDSSHD_AI_ROUTE
Static IPv4 default route. Default: none.
RDSSHD_AI_IPV6
IPv6 address or installer keyword. Default: none.
RDSSHD_AI_DOMAIN
DNS domain. Default: my.domain.
RDSSHD_AI_DNS
DNS nameservers. Default: none.
RDSSHD_FSSIZE
Outer miniroot size in 512-byte blocks. Default: 32768 (16 MiB).
RDSSHD_MRMAKEFSARGS
Embedded-rdroot makefs(8) arguments. The default creates a 20 MiB filesystem matching MINIROOTSIZE=40960. An override must fit the kernel reservation.
BSDOBJDIR
Normal object root used by stock instbin. Default: /usr/obj.
DESTDIR
Optional prefix for installed amd64 boot files below usr/mdec. Default: empty.
RELEASEDIR
Enables install-rdsshd and names its destination.

Boolean values are case-insensitive. Values other than yes and no are rejected.

Defaults and validationrawโ†“
RDSSHD_AUTHORIZED_KEYS?=
RDSSHD_HOST_KEY?=
RDSSHD_CONSOLE_LOCK?=	no
RDSSHD_KERNEL_STACK_PROTECTOR?=	yes
RDSSHD_PORT?=	22
RDSSHD_AI_IF?=	em0
RDSSHD_AI_HOSTNAME?=	rdinstall
RDSSHD_AI_IPV4?=	autoconf
RDSSHD_AI_NETMASK?=	255.255.255.0
RDSSHD_AI_ROUTE?=	none
RDSSHD_AI_IPV6?=	none
RDSSHD_AI_DOMAIN?=	my.domain
RDSSHD_AI_DNS?=	none
RDSSHD_FSSIZE?=	32768
RDSSHD_MRMAKEFSARGS?=	-s 20m \
		-o rdroot,minfree=0,bsize=4096,fsize=512,density=4096

rdsshd-check:
	@if [ -z "${RDSSHD_AUTHORIZED_KEYS}" ]; then \
		echo "set RDSSHD_AUTHORIZED_KEYS to a public key file" >&2; \
		exit 1; \
	fi
	@case "${RDSSHD_CONSOLE_LOCK:L}" in \
	yes|no) ;; \
	*) echo "RDSSHD_CONSOLE_LOCK must be yes or no" >&2; exit 1;; \
	esac
	@case "${RDSSHD_KERNEL_STACK_PROTECTOR:L}" in \
	yes|no) ;; \
	*) echo "RDSSHD_KERNEL_STACK_PROTECTOR must be yes or no" >&2; exit 1;; \
	esac

USE

Prepare a non-interactive next boot from the running system. No boot prompt or console access is assumed.

Whole-device image

minirootXX_sshd.img is a complete disk image. Writing it to a block device replaces the device's partition table and filesystems. Verify the output device before writing it.

Write the miniroot from Linuxraw
# Linux example. This destroys the existing contents of /dev/sda.
dd if=./miniroot79_sshd.img of=/dev/sda bs=512
sync
reboot

The running OS or storage stack may deny raw writes to the initial sectors of the device backing its active root filesystem. Use the boot-loader method if this cannot be changed remotely.

The image boots in UEFI or BIOS/CSM mode with Secure Boot disabled. Firmware must already select the target device. The rdroot runs from memory, so the installer can reuse that device as its target.

GRUB one-shot boot

Copy the uncompressed ramdisk kernel. Do not rely on gzio.

Copy ramdisk kernelraw
# Use the uncompressed kernel; do not rely on gzio.
cp /path/to/bsdsshd.rd /boot/bsdsshd.rd
/etc/grub.d/40_customraw
menuentry "OpenBSD bsdsshd.rd" {
        insmod part_gpt
        insmod ext2
        insmod bsd
        # Replace UUID with the /boot filesystem UUID.
        search --no-floppy --fs-uuid --set=root UUID
        kopenbsd /bsdsshd.rd
}
/etc/default/grubraw
GRUB_DEFAULT=saved
Select one-shot bootraw
update-grub
grub-reboot "OpenBSD bsdsshd.rd"
grub-editenv list

# Expected output:
# next_entry=OpenBSD bsdsshd.rd

sync
reboot

This example was tested in BIOS/CSM mode. UEFI is untested and may lack display console output. Secure Boot is unsupported. part_gpt and ext2 match the tested /boot filesystem. The target layout may require different modules. The GRUB build must provide the bsd module and kopenbsd command. Other boot loaders are untested.

Network booting

The matching OpenBSD/amd64 pxeboot(8) can load the compressed bsdsshd.rd over the network in place of bsd.rd.

Remote session

After the enhanced kernel boots, connect as root when the configured address accepts ssh. Perform required storage preparation, then start the interactive installer.

Remote installraw
ssh -i /path/to/private_key root@host.example
# Perform the required storage setup.
# Enter "done" at the network prompt to preserve network configuration.
install

RDSSHD_CONSOLE_LOCK=yes presents no local installer menu or shell.

REMOVE PATCH

Prepare private state before reversal. The target prints the recorded object path and removes the source obj symlink. /usr/obj is the default; use the printed path when different. patch(1) removes added files but leaves their empty parent directory. rmdir refuses non-empty directories.

Clean and reverse patchraw
cd /usr/src/distrib/amd64/ramdisk_cd/rdsshd
make -f Makefile.rdsshd prepare-unpatch-rdsshd

cd /usr/src
patch -R -p1 < /root/bsdsshd.rd.patch
# Replace /usr/obj below if prepare-unpatch-rdsshd printed another path.
rmdir /usr/obj/distrib/amd64/ramdisk_cd/rdsshd
rmdir /usr/src/distrib/amd64/ramdisk_cd/rdsshd

FILES

/usr/src/distrib/amd64/ramdisk_cd/rdsshd
Added source directory and wrapper entry point.
obj/bsdsshd.rd
Uncompressed enhanced ramdisk kernel.
obj/bsdsshd.gz
Stripped and compressed kernel installed as bsdsshd.rd by the release target and as /bsd in the enhanced miniroot.
obj/minirootXX_sshd.img
Enhanced BIOS- and EFI-bootable miniroot image.
obj/vnd
Private vnd ownership record. Removed after successful assembly or safe cleanup.

DIAGNOSTICS

private object directory is not active
Run the wrapper obj target as a separate invocation.
normal OpenBSD object root does not exist
Create BSDOBJDIR with normal OpenBSD ownership and permissions.
normal ramdisk_cd object directory is not active
The stock obj target did not select an object directory distinct from its source directory. Check BSDOBJDIR and rerun the build.
stock ramdisk_cd instbin was not built
Inspect the preceding stock-target failure. Clean ramdisk_cd before retrying after a failed trace link.
invalid public key on line N of RDSSHD_AUTHORIZED_KEYS
Replace the indicated line with a key accepted by ssh-keygen(1).
RDSSHD_HOST_KEY is not an unencrypted Ed25519 private key
Supply an Ed25519 host private key without a passphrase.
stale private vnd state
Run unconfig-rdsshd before rebuilding the miniroot.
refusing to detach vnd not owned by rdsshd
The recorded device no longer covers the enhanced image. The target leaves it attached for manual inspection.

SEE ALSO

make(1), patch(1), ssh(1), ssh-keygen(1), softraid(4), vnd(4), disktab(5), install.site(5), sshd_config(5), autoinstall(8), bioctl(8), boot(8), boot_amd64(8), config(8), installboot(8), makefs(8), rdsetroot(8), release(8), sshd(8), and vnconfig(8).

various config files

config files

A place for config files to hangout and configure things

ngIRCd

An OpenBSD chroot orientated config for the awesome ngIRCd IRC daemon, likely useful on other platforms.

Note that there's a small DoS issue in the packaged version of ngIRCd for OpenBSD 7.9, so you might want to build a newer version from OpenBSD ports.

There also seems to be some great work being done on pledge/unveil integration upstream.

ngircd.confChrooted ngIRCd config for OpenBSDrawโ†“
# Hardened for the OpenBSD net/ngircd package (ngIRCd 27).
#
# When ChrootDir is enabled, prefer "rcctl restart ngircd" over reload.
# A reload is safe only when the same configuration is readable at
# /var/ngircd/etc/ngircd/ngircd.conf from inside the jail.

[Global]
# Placeholder: Name = <IRC_SERVER_FQDN>
# Example: irc.example.com
Name = irc.arachnogoat.com

# Placeholder: Info = <SERVER_DESCRIPTION>
# Example: Example private IRC server
Info = arachnogoat IRC server

# Placeholder: Network = <IRC_NETWORK_NAME> (ASCII without spaces)
# Example: ExampleIRC
Network = ArachnoIRC

# Placeholder: Password = <64_HEX_RANDOM_SECRET>
# Generate one with: openssl rand -hex 32
# Configure this as the IRC server password in every authorized client.
# This shared password controls admission to the whole server; ngIRCd 27's
# OpenBSD package does not provide a built-in per-user account database.
Password = example_password

# Disable the default unencrypted listener on port 6667.
Ports =

# OpenBSD rc.d does not require a PID file. Leaving this empty also lets the
# OpenBSD pledge(2) patch omit its cpath promise and keeps the jail read-only.
PidFile =

ServerUID = _ngircd
ServerGID = _ngircd

# Placeholder: MotdPhrase = <WELCOME_MESSAGE> (maximum 126 characters)
# Example: Welcome to the example IRC server.
MotdPhrase = Welcome to the arachnogoat IRC server.

# Placeholder: Listen = <PUBLIC_IPV4>,<PUBLIC_IPV6>
# Example using RFC documentation addresses (do not deploy these addresses):
# Listen = 192.0.2.10,2001:db8::10
# If omitted, ngIRCd listens on every local IPv4 and IPv6 address.
Listen = 51.83.6.53

# These limits are intentionally low
[Limits]
MaxConnections = 10
MaxConnectionsIP = 5
MaxJoins = 1
MaxListSize = 1

[Options]
# Placeholder: ChrootDir = <CHROOT_DIRECTORY>
# OpenBSD package example: /var/ngircd
ChrootDir = /var/ngircd

# Only the predefined channel below may exist.
AllowedChannelTypes =

AllowRemoteOper = no
DNS = no
Ident = no
DefaultUserModes = iI
MorePrivacy = yes
ScrubCTCP = yes

# Hide client IP addresses, user names, and real names from other clients.
# The cloak hash salt is intentionally generated afresh at each server start.
# Placeholder: CloakHost = hidden-%x.<CLOAK_SUFFIX>
# Example: hidden-%x.example-irc
CloakHost = hidden-%x.arachnoirc
CloakUserToNick = yes

NoticeBeforeRegistration = no
OperCanUseMode = no
OperChanPAutoOp = no
OperServerMode = no
WebircPassword =
SyslogFacility = daemon

[SSL]
# Placeholder: CertFile = <FULL_CHAIN_CERTIFICATE_FILE>
# OpenBSD acme-client example: /etc/ssl/irc.example.com.fullchain.pem
CertFile = /etc/ssl/irc.arachnogoat.com.fullchain.pem

# Placeholder: KeyFile = <PRIVATE_KEY_FILE>
# OpenBSD acme-client example: /etc/ssl/private/irc.example.com.key
KeyFile = /etc/ssl/private/irc.arachnogoat.com.key

# TLS 1.3 plus forward-secret AEAD suites for TLS 1.2 (LibreSSL syntax).
CipherList = TLSv1.3:ECDHE+AESGCM:ECDHE+CHACHA20

# Placeholder: Ports = <TLS_PORT>
# Standard encrypted IRC example: Ports = 6697
Ports = 6697

[Channel]
# Placeholder: Name = <CHANNEL_NAME>, including its # prefix
# Example: #arachnochat
Name = #arachnochat

# Placeholder: Topic = <CHANNEL_TOPIC>
# Example: Private chat
Topic = Private chat
Modes = +sntz
Autojoin = yes

# Optional per-user channel keys (disabled)
# -----------------------------------------
#
# NOTE: This per-user example configuration is untested in my environment,
#       Fully test and verify if you intend to follow these instructions.
#
# This is only a limited approximation of user accounts. Nicknames are not
# registered identities, and every client must still supply the shared global
# Password above before registering with the server.
#
# To enable separate channel-entry keys for user1, user2, and user3:
#
# 1. Generate four independent secrets with "openssl rand -hex 32": one
#    undisclosed master channel key plus one key for each user.
# 2. Replace the active channel settings above with:
#
#      Modes = +sntzNk <RANDOM_MASTER_CHANNEL_KEY>
#      Autojoin = no
#      KeyFile = /etc/ngircd/arachnochat.keys
#
#    Mode +k enables channel-key checking, and +N prevents nickname changes
#    while a user is in the channel. Autojoin must be disabled because it
#    cannot provide an individual channel key.
# 3. Create key file inside the chroot:
#
#      /var/ngircd/etc/ngircd/arachnochat.keys
#
#    Its format is "user:nick:key", without spaces. Because this configuration
#    uses "CloakUserToNick = yes", match any user name and the exact nickname:
#
#      *:user1:<RANDOM_KEY_FOR_USER1>
#      *:user2:<RANDOM_KEY_FOR_USER2>
#      *:user3:<RANDOM_KEY_FOR_USER3>
#
# 4. Secure the file as root-owned, readable by _ngircd, and not writable by
#    the daemon:
#
#      chown root:_ngircd /var/ngircd/etc/ngircd/arachnochat.keys
#      chmod 0440 /var/ngircd/etc/ngircd/arachnochat.keys
#
# 5. Restart ngIRCd. Each client chooses its assigned nickname and joins with:
#
#      /JOIN #arachnochat <THE_USER'S_CHANNEL_KEY>
#
# Security limitation: channel members can retrieve the +k master channel key
# using MODE and could share it. This mechanism provides convenience and
# separately revocable user keys, but it is not a strong account/ACL system.

Samba

An OpenBSD (on FFS2 filesystem) optimised config for Samba built to work with MacOS/iPadOS/iOS and MS Windows clients.

smb.confSamba config for OpenBSDrawโ†“
# Samba 4.22 on OpenBSD/FFS2.
[global]
   server role = standalone server
   workgroup = WORKGROUP
   security = user
   passdb backend = tdbsam

   ntlm auth = ntlmv2-only
   map to guest = never
   guest ok = no

   usershare max shares = 0

   # Enumerate only shares accessible to the authenticated user.
   access based share enum = yes

   disable netbios = yes
   smb ports = 445
   server min protocol = SMB3_00
   server max protocol = SMB3_11

   # Per-share encryption; sign unencrypted traffic.
   server smb encrypt = desired
   server signing = mandatory

   unix charset = UTF-8
   case sensitive = auto

   # No experimental SMB3 POSIX extensions.
   smb3 unix extensions = no

   wide links = no
   getwd cache = yes

   # Parent permissions override creation masks.
   inherit permissions = yes

   delete readonly = yes

   dos filemode = yes
   dos filetimes = yes

   # Directory leases require userspace oplocks and imply strict rename.
   oplocks = yes
   level2 oplocks = yes
   kernel oplocks = no
   smb2 leases = yes
   smb3 directory leases = yes

   # POSIX lock interoperability; disables durable handles.
   kernel share modes = no
   posix locking = yes

   strict locking = auto

   aio read size = 1
   aio write size = 1

   # Honour flushes without fsync on every write.
   strict sync = yes
   sync always = no

   # Avoid FFS2 preallocation overhead.
   strict allocate = no

   # Unsupported on OpenBSD.
   server multi channel support = no
   use sendfile = no

   # OpenBSD mkdir workaround (Samba bug 15801).
   vfs mkdir use tmp name = no

   # No native FFS2 xattrs; preserve existing ADS/EA backends and paths.
   vfs objects = streams_depot xattr_tdb
   ea support = yes
   store dos attributes = yes
   streams_depot:directory = /storage/samba/.streams_depot
   xattr_tdb:file = /storage/var/db/samba/xattr.tdb

   # Retain displaced streams as lost-* instead of deleting.
   streams_depot:delete_lost = no

   multicast dns register = no

   load printers = no
   disable spoolss = yes
   printcap name = /dev/null

   logging = file
   log level = 0
   max log size = 1000


[share]
   path = /storage/samba/share
   read only = no

   valid users = fileshare
   force user = fileshare
   force group = smbusers

   server smb encrypt = desired


[work]
   # Note: This share optimised for Windows clients
   path = /storage/samba/work
   read only = no

   valid users = filework
   force user = filework
   force group = smbusers

   # Avoid depot/TDB overhead; no ADS or persistent DOS attributes.
   vfs objects =
   ea support = no
   store dos attributes = no

   # Do not map DOS flags onto Unix modes.
   map archive = no
   map hidden = no
   map system = no
   map readonly = no


[private]
   path = /storage/samba/private
   read only = no

   valid users = fileprivate
   force user = fileprivate
   force group = smbusers

   server smb encrypt = required

various dotfiles

dotfiles

A place for dotfiles to hangout and do dotfile things

.vimrc

.vimrcAn uncomplicated .vimrcrawโ†“
" ~/.vimrc

" Syntax and filetype handling.
" Enable highlighting, indentation, and HTML-aware % matching.
syntax on
filetype plugin indent on
packadd! matchit

" Security.
" Ignore Vim settings embedded inside edited files.
set nomodeline

" Interface.
" Show useful position information with a little context around the cursor.
set number
set ruler
set laststatus=2
set scrolloff=3

" Searching.
" Search as you type, highlight matches, and ignore case unless capitals are used.
set incsearch
set hlsearch
set ignorecase
set smartcase
set showmatch

" Indentation.
" Use two-space soft tabs and insert spaces instead of literal tab characters.
set tabstop=2
set shiftwidth=2
set softtabstop=2
set expandtab

" Terminal.
" Disable the bell and assume a dark terminal background.
set visualbell
set t_vb=
set background=dark

various scripts

scripts

A place for scripts to hangout and script things

Blur optimised ffmpeg frame extractor

Who doesn't want rotating wallpaper sets extracted from their favourite videos!?

frame_extract.shA blur optimised frame to image extractorrawโ†“
#!/bin/sh

# Extracts the sharpest still frame near each regular time interval.
# Candidates within WINDOW are scored with FFmpeg blurdetect; the nearest is
# used if none has a finite score. Exact selected frames are written as lossless
# PNGs, optionally Lanczos-scaled to cover and centre-cropped to an exact size.
# No sharpening, frame interpolation, or deinterlacing; existing paths are not overwritten.

set -eu

LC_ALL=C
export LC_ALL

usage() {
    printf 'Usage: %s [-w WINDOW] [-o OUTDIR] [-s WIDTHxHEIGHT] INTERVAL INPUT\n' "$0" >&2
    exit 2
}

window=0.25
out=frames
size=
size_set=0

while getopts 'w:o:s:' opt; do
    case $opt in
        w) window=$OPTARG ;;
        o) out=$OPTARG ;;
        s) size=$OPTARG; size_set=1 ;;
        *) usage ;;
    esac
done
shift $((OPTIND - 1))

[ "$#" -eq 2 ] || usage

interval=$1
input=$2

if [ "$size_set" -eq 1 ]; then
    case $size in
        *[!0-9x]* | *x*x* | x* | *x) usage ;;
    esac

    width=${size%%x*}
    height=${size#*x}

    case $width in *[1-9]*) ;; *) usage ;; esac
    case $height in *[1-9]*) ;; *) usage ;; esac
else
    width=
    height=
fi

awk -v i="$interval" -v w="$window" '
    BEGIN {
        re = "^[0-9]+([.][0-9]+)?$"
        exit !(i ~ re && w ~ re && i > 0 && w >= 0 && w < i / 2)
    }
' || {
    printf '%s\n' \
        "INTERVAL must be > 0; WINDOW must be >= 0 and < INTERVAL/2" >&2
    exit 2
}

command -v ffmpeg >/dev/null 2>&1 || {
    printf '%s\n' "ffmpeg not found" >&2
    exit 127
}

# Force relative pathnames to be treated as local files, not FFmpeg URLs.
case $input in /*) ;; *) input=./$input ;; esac
case $out   in /*) ;; *) out=./$out ;; esac

[ -f "$input" ] || {
    printf 'Input is not a regular file: %s\n' "$input" >&2
    exit 1
}

[ ! -e "$out" ] && [ ! -L "$out" ] || {
    printf 'Output path already exists: %s\n' "$out" >&2
    exit 1
}

tmpbase=${TMPDIR:-/tmp}
case $tmpbase in /*) ;; *) tmpbase=./$tmpbase ;; esac

tmp=$(mktemp -d "$tmpbase/bestframes.XXXXXX") || {
    printf '%s\n' "Could not create temporary directory" >&2
    exit 1
}

[ -n "$tmp" ] && [ -d "$tmp" ] || {
    printf '%s\n' "mktemp did not create a temporary directory" >&2
    exit 1
}

cleanup() {
    rm -f "$tmp/meta" "$tmp/picks" "$tmp/filter" 2>/dev/null || :
    rmdir "$tmp" 2>/dev/null || :
}
trap cleanup 0
trap 'exit 1' HUP INT TERM

printf '[1/2] Analysing candidate frames...\n' >&2

ffmpeg -nostdin -hide_banner -loglevel error \
    -stats -stats_period 2 \
    -i "$input" \
    -map 0:v:0 \
    -vf "setpts=PTS-STARTPTS,select='lte(abs(t-round(t/$interval)*$interval)\,$window)',blurdetect=block_width=32:block_height=32:block_pct=80,metadata=mode=print:key=lavfi.blur:file=-" \
    -f null /dev/null \
    >"$tmp/meta"

awk -v interval="$interval" '
    /^frame:/ {
        pts = time = ""

        for (i = 1; i <= NF; i++) {
            if ($i ~ /^pts:/) {
                pts = $i
                sub(/^pts:/, "", pts)
            } else if ($i ~ /^pts_time:/) {
                time = $i
                sub(/^pts_time:/, "", time)
            }
        }
        next
    }

    /^lavfi\.blur=/ && pts != "" && time != "" {
        blur = $0
        sub(/^[^=]*=/, "", blur)

        b = int(time / interval + 0.5)
        dist = time - b * interval
        if (dist < 0)
            dist = -dist

        # Fallback: nearest candidate if no finite blur score is available.
        if (!(b in nearpts) || dist < neardist[b]) {
            nearpts[b] = pts
            neardist[b] = dist
        }

        if (blur ~ /^[+-]?(([0-9]+([.][0-9]*)?)|([.][0-9]+))([eE][+-]?[0-9]+)?$/) {
            score = blur + 0

            if (!(b in bestpts) ||
                score < bestblur[b] ||
                (score == bestblur[b] && dist < bestdist[b])) {
                bestpts[b] = pts
                bestblur[b] = score
                bestdist[b] = dist
            }
        }
    }

    END {
        for (b in nearpts) {
            if (b in bestpts)
                print bestpts[b]
            else
                print nearpts[b]
        }
    }
' "$tmp/meta" >"$tmp/picks"

count=$(awk 'END { print NR + 0 }' "$tmp/picks")

[ "$count" -gt 0 ] || {
    printf '%s\n' "No suitable frames found" >&2
    exit 1
}

printf '[1/2] Analysis complete: %s frames selected.\n' "$count" >&2

# Build a balanced expression tree. This avoids FFmpeg parser-depth
# problems from a long flat eq()+eq()+eq()+... expression. If requested,
# scale to cover the target with square pixels, then crop exactly at centre.
awk -v width="$width" -v height="$height" '
    {
        a[NR] = "eq(pts\\," $1 ")"
    }

    END {
        n = NR

        while (n > 1) {
            m = 0

            for (i = 1; i <= n; i += 2) {
                m++

                if (i < n)
                    b[m] = "(" a[i] "+" a[i + 1] ")"
                else
                    b[m] = a[i]
            }

            for (i = 1; i <= m; i++)
                a[i] = b[i]

            n = m
        }

        filter = "setpts=PTS-STARTPTS,select=" a[1]

        if (width != "")
            filter = filter ",scale=w=" width ":h=" height \
                ":force_original_aspect_ratio=increase" \
                ":flags=lanczos+accurate_rnd+full_chroma_int:reset_sar=1" \
                ",crop=w=" width ":h=" height ":exact=1"

        print filter
    }
' "$tmp/picks" >"$tmp/filter"

mkdir "$out" || {
    printf 'Could not create output directory: %s\n' "$out" >&2
    exit 1
}

printf '[2/2] Extracting %s lossless PNG frames...\n' "$count" >&2

if ! ffmpeg -nostdin -hide_banner -loglevel error \
    -stats -stats_period 2 -n \
    -i "$input" \
    -map 0:v:0 \
    -/filter:v "$tmp/filter" \
    -fps_mode passthrough \
    -start_number 0 \
    "$out/frame_%06d.png"
then
    printf 'Extraction failed; partial output may remain in: %s\n' "$out" >&2
    exit 1
fi

actual=0
for file in "$out"/frame_*.png; do
    [ -f "$file" ] || continue
    actual=$((actual + 1))
done

[ "$actual" -eq "$count" ] || {
    printf 'Expected %s frames but wrote %s; output left in: %s\n' \
        "$count" "$actual" "$out" >&2
    exit 1
}

printf 'Done: %s frames written to %s/\n' "$count" "$out" >&2

ffmpeg parallel AAC-LC converter

The Malazan audiobooks are too huge for Rockbox when using AAC-HE encoding

m4b2aac.shParallel ffmpeg AAC-LC conversionrawโ†“
#!/bin/sh
# Convert M4B/M4A files to AAC-LC, preserving tags, chapters and artwork.
#
# -j count: concurrent conversions (default 2).
# -b kbps: total audio bitrate, a positive integer in kb/s (default 96).
# -c channels: 1 for mono, 2 for stereo (default 2).
# -o file: exact output path, for one input only; use .m4b or .m4a.
# -d directory: existing output directory; keep input stems and extension type.
# -s suffix: append this to generated stems (default .aac-lc, or empty with -d).
# -o cannot be combined with -d or -s. Output directories must already exist.
#
# One file, several files, or several patterns:
#   ./m4b2aac.sh -- './Book One.m4b'
#   ./m4b2aac.sh -j 4 -- './Book One.m4b' './Book Two.m4a'
#   ./m4b2aac.sh -j 4 -- ./originals/*.m4b ./more/*.m4a
#
# Bitrate choices: 96 stereo for quality margin; 64 stereo for smaller files;
# 48-64 mono for narration.
#   ./m4b2aac.sh -b 48 -c 1 -s '.mono' -- './Book.m4b'
#
# Exact output name, custom suffix, or a separate output directory:
#   ./m4b2aac.sh -o './Book LC.m4b' -- './Book.m4b'
#   ./m4b2aac.sh -s '.rockbox' -- ./originals/*.m4b
#   mkdir -p ./m4b-aac-lc &&
#   ./m4b2aac.sh -d ./m4b-aac-lc -j 4 -- ./*.m4b
#   ./m4b2aac.sh -d ./m4b-aac-lc -s '.lc' -- './Book.m4b'
#
# Manual batch changing .m4a to .m4b; stop at the first error:
#   mkdir -p ./converted &&
#   for file in ./originals/*.m4a; do
#       name=${file##*/}
#       ./m4b2aac.sh -o "./converted/${name%.*}.m4b" -- "$file" || break
#   done
#
# Recursive conversion beside originals; exclude staging and generated files:
#   find ./books -type d -name '.m4b2aac.*' -prune -o \
#       -type f -name '*.[mM]4[aAbB]' ! -name '*.aac-lc.m4[ab]' \
#       -exec /absolute/path/m4b2aac.sh -j 4 -- {} +
# With -d, filenames are flattened; output filenames must be distinct.
# Use non-overlapping input patterns, and exclude outputs on subsequent runs.

usage() {
    printf 'Usage: %s [-j jobs] [-b kbps] [-c 1|2] [-d directory] [-s suffix] [--] file ...\n       %s [-j jobs] [-b kbps] [-c 1|2] -o output [--] file\n' "$0" "$0" >&2
    exit 2
}
jobs=2
bitrate=96
channels=2
output_file=
target_dir=
suffix=.aac-lc
suffix_set=
while getopts 'j:b:c:o:d:s:' option; do
    case $option in
        j) jobs=$OPTARG ;;
        b) bitrate=$OPTARG ;;
        c) channels=$OPTARG ;;
        o) output_file=$OPTARG; [ -n "$output_file" ] || usage ;;
        d) target_dir=$OPTARG; [ -n "$target_dir" ] || usage ;;
        s) suffix=$OPTARG; suffix_set=1 ;;
        *) usage ;;
    esac
done
shift "$((OPTIND - 1))"
for count in "$jobs" "$bitrate"; do
    case $count in ''|*[!0-9]*) usage ;; esac
    [ "$count" -gt 0 ] 2>/dev/null || usage
done
case $channels in 1|2) ;; *) usage ;; esac
[ "$#" -gt 0 ] || usage
case $suffix in */*) usage ;; esac
if [ -n "$output_file" ]; then
    [ "$#" -eq 1 ] && [ -z "$target_dir$suffix_set" ] || usage
    case $output_file in *.[mM]4[aAbB]) ;; *) usage ;; esac
    case $output_file in /*|./*|../*) ;; *) output_file=./$output_file ;; esac
fi
if [ -n "$target_dir" ]; then
    case $target_dir in /*|./*|../*) ;; *) target_dir=./$target_dir ;; esac
    [ -d "$target_dir" ] || {
        printf 'Not a directory: %s\n' "$target_dir" >&2; exit 1
    }
    [ -n "$suffix_set" ] || suffix=
fi
for tool in ffmpeg mktemp; do
    command -v "$tool" >/dev/null 2>&1 || {
        printf 'Missing command: %s\n' "$tool" >&2; exit 1
    }
done

paths() {
    # Prefix bare paths so FFmpeg and POSIX utilities cannot read them as options.
    case $1 in /*|./*|../*) file=$1 ;; *) file=./$1 ;; esac
    name=${file##*/}
    case $name in *.[mM]4[aA]) extension=m4a ;; *) extension=m4b ;; esac
    directory=${target_dir:-${file%/*}}
    output=${output_file:-${directory%/}/${name%.*}$suffix.$extension}
    directory=${output%/*}
    [ -n "$directory" ] || directory=/
    name=${output##*/}
}

work=$(mktemp -d "${TMPDIR:-/tmp}/m4b2aac.XXXXXX") || exit 1
token=.${work##*/}
pending=
status=0
polls=0
# shellcheck disable=SC2329 # Called by the exit trap.
cleanup() {
    trap '' HUP INT TERM
    # Entries contain only a child PID and a numeric job ID.
    for job in $pending; do
        [ -f "$work/${job#*:}.done" ] || kill -s TERM "${job%:*}" 2>/dev/null || :
    done
    for job in $pending; do wait "${job%:*}" 2>/dev/null || :; done
    for record in "$work"/*.directory; do
        [ -f "$record" ] || continue
        stage=$(cat "$record") || continue
        case $stage in */"$token") rm -rf "$stage" ;; esac
    done
    rm -rf "$work"
}
trap cleanup 0
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM

# Reserve names on each destination filesystem before starting any encoders.
# A shared random name also recognises directory aliases without parsing paths.
printf '%s\n' "$work" >"$work/owner" || exit 1
id=0
for file do
    id=$((id + 1))
    case $file in *.[mM]4[aAbB]) ;; *) continue ;; esac
    [ -f "$file" ] || continue
    paths "$file"
    [ -e "$output" ] || [ -L "$output" ] || {
        stage=$directory/$token
        if mkdir -m 700 "$stage" 2>/dev/null; then
            printf '%s' "$stage" >"$work/$id.directory" || {
                rm -f "$work/$id.directory"
                rmdir "$stage"; exit 1
            }
            cp "$work/owner" "$stage/owner" && mkdir "$stage/names" || exit 1
        elif ! cmp -s "$work/owner" "$stage/owner"; then
            printf 'Cannot stage output in: %s\n' "$directory" >&2; exit 1
        fi
        mkdir "$stage/names/$name" 2>/dev/null || {
            printf 'Conflicting or invalid output filename: %s\n' "$output" >&2; exit 1
        }
    }
done

# The background invocation supplies the subshell; avoid another process layer.
convert() {
    id=$1
    paths "$2"
    encoder=
    # shellcheck disable=SC2329 # Called by the exit trap.
    finish() {
        trap '' HUP INT TERM
        if [ -n "$encoder" ]; then
            kill -s TERM "$encoder" 2>/dev/null || :
            wait "$encoder" 2>/dev/null || :
        fi
        : >"$work/$id.done"
    }
    trap finish 0
    trap 'exit 129' HUP
    trap 'exit 130' INT
    trap 'exit 143' TERM

    case $file in
        *.[mM]4[aAbB]) ;;
        *) printf 'Expected an M4B/M4A file: %s\n' "$file" >&2; exit 1 ;;
    esac
    [ -f "$file" ] || {
        printf 'Not a regular file: %s\n' "$file" >&2; exit 1
    }
    if [ -e "$output" ] || [ -L "$output" ]; then
        printf 'Exists, skipping: %s\n' "$output" >&2; exit 0
    fi
    tmp=$directory/$token/$id
    mkdir "$tmp" || exit 1
    printf '[%s] Started: %s\n' "$id" "$file" >&2
    # Separate inputs avoid FFmpeg 9 truncating these books when copying artwork.
    ffmpeg -hide_banner -nostdin -nostats -v error -xerror -n \
        -stats_period 15 -progress "$work/$id.progress" \
        -i "$file" -i "$file" -map 0:a:0 -map '1:v:disp:attached_pic?' \
        -af 'aresample=async=1:first_pts=0' \
        -c:a aac -profile:a aac_low -ar 24000 \
        -b:a "${bitrate}k" -ac "$channels" -c:v copy \
        "$tmp/$name" >"$tmp/log" 2>&1 &
    encoder=$!
    result=0
    wait "$encoder" || result=$?
    encoder=
    if [ "$result" -ne 0 ] || [ ! -s "$tmp/$name" ]; then
        printf '[%s] Failed: %s\n' "$id" "$file" >&2
        cat "$tmp/log" >&2
        exit 1
    fi
    # EOF declines overwrite if a destination appeared during encoding.
    mv -i "$tmp/$name" "$directory/" </dev/null &&
        [ ! -f "$tmp/$name" ] || {
        printf '[%s] Cannot publish: %s\n' "$id" "$output" >&2; exit 1
    }
    printf '[%s] Created: %s\n' "$id" "$output"
}

reap() {
    remaining=
    active=0
    polls=$(((polls + 1) % 15))
    for job in $pending; do
        if [ -f "$work/${job#*:}.done" ] || ! kill -s 0 "${job%:*}" 2>/dev/null; then
            wait "${job%:*}" || status=1
        else
            remaining="$remaining $job"
            active=$((active + 1))
            if [ "$polls" -eq 0 ] && [ -f "$work/${job#*:}.progress" ]; then
                elapsed=$(sed -n 's/^out_time=//p' "$work/${job#*:}.progress" | tail -n 1)
                [ -z "$elapsed" ] ||
                    printf '[%s] Encoded: %s\n' "${job#*:}" "${elapsed%.*}" >&2
            fi
        fi
    done
    pending=$remaining
}
id=0
active=0
for file do
    while [ "$active" -ge "$jobs" ]; do
        reap
        [ "$active" -lt "$jobs" ] || sleep 1
    done
    id=$((id + 1))
    convert "$id" "$file" &
    pending="$pending $!:$id"
    active=$((active + 1))
done
while [ -n "$pending" ]; do
    reap
    [ -z "$pending" ] || sleep 1
done
exit "$status"

ffprobe CUE file generator

The Malazan audiobooks can have proper chapter navigation and title text

m4b2cue.shffprobe CUE file generatorrawโ†“
#!/bin/sh
# Create a Rockbox cuesheet beside each M4B/M4A file operand.
#
# Single, multiple, and multiple glob patterns:
#   ./m4b2cue.sh -- './Book One.m4b'
#   ./m4b2cue.sh -- './Book One.m4b' '../Book Two.m4a'
#   ./m4b2cue.sh -- ./converted/*.m4b ./other/*.m4a
#   find ./books -type f -name '*.[mM]4[aAbB]' \
#       -exec /absolute/path/m4b2cue.sh -- {} +
#
# -m supplies one book's mapping: first source track number, then title.
# Numbers are 1-based indexes into that recording's extracted sections.
# -m requires exactly one audio input, even if a glob supplied the input.
# Mapping filenames need not match the audio name:
#   ./m4b2cue.sh -m './Book.chapters.txt' -- './Book.aac-lc.m4b'
#
# Manual batch: originals and mappings here, converted files in ./m4b-aac-lc.
# Stop at the first error:
#   for file in ./*.m4b; do
#       ./m4b2cue.sh -m "${file%.m4b}.chapters.txt" \
#           -- "./m4b-aac-lc/${file#./}" || break
#   done
#
# Recursive mapping when each input has a matching Book.chapters.txt beside it:
#   find ./books -type f -name '*.[mM]4[aAbB]' -exec sh -c '
#       /absolute/path/m4b2cue.sh -m "${1%.*}.chapters.txt" -- "$1"
#   ' sh {} \;
# Missing/invalid mappings fail; they never fall back to ungrouped chapters.

usage() {
    printf 'Usage: %s [--] file ...\n       %s -m mapping [--] file\n' "$0" "$0" >&2
    exit 2
}
mapping=
while getopts 'm:' option; do
    case $option in
        m) mapping=$OPTARG; [ -n "$mapping" ] || usage ;;
        *) usage ;;
    esac
done
shift "$((OPTIND - 1))"
[ "$#" -gt 0 ] || usage
if [ -n "$mapping" ]; then
    [ "$#" -eq 1 ] || usage
    [ -f "$mapping" ] || {
        printf 'Not a regular mapping file: %s\n' "$mapping" >&2
        exit 1
    }
fi
for tool in ffprobe jq mktemp; do
    command -v "$tool" >/dev/null 2>&1 || {
        printf 'Missing command: %s\n' "$tool" >&2
        exit 1
    }
done

LC_ALL=C
export LC_ALL
trap 'exit 1' HUP INT TERM

convert() (
    case $1 in /*|./*|../*) file=$1 ;; *) file=./$1 ;; esac
    case $file in
        *.[mM]4[aAbB]) ;;
        *) printf 'Expected an M4B/M4A file: %s\n' "$file" >&2; exit 1 ;;
    esac
    [ -f "$file" ] || {
        printf 'Not a regular file: %s\n' "$file" >&2
        exit 1
    }
    directory=${file%/*}/
    cue=${file%.*}.cue
    # Rockbox associates both Book.m4b and Book.m4a with Book.cue.
    set -- "${file%.*}".[mM]4[aAbB]
    if [ "$#" -gt 1 ]; then
        printf 'Conflicting audio filenames: %s\n' "$file" >&2
        exit 1
    fi
    if [ -e "$cue" ] || [ -L "$cue" ]; then
        printf 'Exists, skipping: %s\n' "$cue" >&2
        exit 0
    fi
    tmp=$(mktemp -d "${directory}.m4b2cue.XXXXXX") || exit 1
    staged=$tmp/${cue##*/}
    trap 'rm -f "$tmp/metadata" "$staged"; rmdir "$tmp"' 0
    trap 'exit 1' HUP INT TERM
    # Separate commands let POSIX sh check both exits without pipefail.
    ffprobe -v error -of json \
        -show_entries 'chapter=start_time:chapter_tags:format_tags' \
        "$file" >"$tmp/metadata" &&
    jq -er --arg file "${file##*/}" --arg mapping_path "$mapping" \
        --rawfile mapping "${mapping:-/dev/null}" '
        def tags: .tags // {} | with_entries(.key |= ascii_downcase)
            | with_entries(select(.value != ""));
        def text:
            .[:240] | gsub("[\u0000-\u001f\u007f-\u009f]"; " ")
            | gsub("\""; "\u0027")
            | until(utf8bytelength <= 240; .[:-1]);
        def pad: tostring | if length < 2 then "0" + . else . end;
        def index:
            [(./4500 | floor | pad), (./75 | floor % 60 | pad),
             (. % 75 | pad)] | join(":");

        if ($file | test("[\"\u0000-\u001f\u007f-\u009f\ufffd]"))
            or ($file | utf8bytelength > 247) then
            error("filename cannot be represented safely in a Rockbox cuesheet")
        else . end
        | if $mapping_path != "" then
            (.chapters // []) as $tracks
            | [$mapping | ltrimstr("\uFEFF") | split("\n")[]
                | gsub("^[ \t]+|[ \t\r]+$"; "") | select(length > 0)
                | (capture("^(?<track>[0-9]+)[ \t]+(?<title>.+)$")
                    // error("mapping lines must contain a track number and title"))
                | .track |= tonumber] as $groups
            | if ($groups | length) == 0 or $groups[0].track != 1
                or any($groups[]; .track > ($tracks | length))
                or any(range(1; $groups | length);
                       $groups[.].track <= $groups[. - 1].track) then
                error("mapping must start at track 1 and increase within the source track count")
              else .chapters = [$groups[] |
                $tracks[.track - 1] + {tags: {title: .title}}] end
          else . end
        | (.chapters // []) as $chapters
        # This parser stops at TRACK 99, before reading its TITLE/INDEX.
        | if ($chapters | length) == 0 then error("no chapters")
          elif ($chapters | length) > 98 then
            error("this Rockbox parser supports only 98 complete chapters")
          else . end
        | [$chapters[] | .start_time | tonumber
            | if isfinite and . >= 0 and . < 1800060 then
                . * 75 | floor
              else error("chapter start is outside Rockbox limits") end] as $frames
        | if any(range(1; $frames | length);
                 $frames[.] <= $frames[. - 1]) then
            error("chapter starts must increase at 1/75-second resolution")
          else . end
        | (.format | tags) as $tags
        | "\uFEFFTITLE \"\(($tags.title // $tags.album // $file) | text)\"",
          "PERFORMER \"\(($tags.artist // $tags.album_artist // "") | text)\"",
          "FILE \"\($file)\" MP3",
          ($chapters | to_entries[] | .key as $i |
            "  TRACK \($i + 1 | pad) AUDIO",
            "    TITLE \"\((.value | tags | .title // "Chapter \($i + 1)") | text)\"",
            "    INDEX 01 \($frames[$i] | index)")
    ' "$tmp/metadata" >"$staged" &&
    # -i with EOF declines replacement if a cue appeared during probing.
    # Target the parent directory so a new directory at $cue is an error.
    mv -i "$staged" "$directory" </dev/null && [ ! -f "$staged" ] &&
    printf 'Created: %s\n' "$cue"
)

status=0
for file do
    convert "$file" || {
        printf 'Failed: %s\n' "$file" >&2
        status=1
    }
done
exit "$status"

AlmaLinux guide

IRC with Irssi and tmux

Keep a persistent Irssi connection available across SSH sessions.

DESCRIPTION

Irssi provides the IRC client. tmux keeps the client running after an SSH connection closes and allows the same terminal session to be resumed later.

The examples connect securely to irc.arachnogoat.com on port 6697 and join #chat. Replace YourNick and SERVER_PASSWORD before use.

INSTALLATION

Install tmux and Irssi from the AlmaLinux package repositories. Run the command from a normal shell with an account permitted to use sudo.

Install tmux and IrssiRun from a shell on AlmaLinuxraw
sudo dnf install tmux irssi

START A SESSION

Create a tmux session named irc. The command enters that session immediately.

Start the IRC tmux sessionraw
tmux new -s irc

Run irssi inside the new tmux session. Irssi then occupies the tmux window until it exits or the session is detached.

CONNECT

Enter the following commands at the Irssi prompt. The connection uses TLS with certificate verification, then joins #chat.

Initial Irssi connectionReplace the nickname and server passwordraw
/SET nick YourNick
/CONNECT -tls -tls_verify irc.arachnogoat.com 6697 SERVER_PASSWORD
/JOIN #chat

NAVIGATION

Irssi normally places server status and the joined channel in separate windows. Use /WIN 1 for the first window and /WIN 2 for the second. The status bar shows the actual window numbers when they differ.

AUTOMATIC CONNECTION

Add a named network, its server, and the channel to Irssi, then save the configuration. Irssi will reconnect and join #chat on later starts.

Persistent Irssi configurationReplace SERVER_PASSWORD before useraw
/NETWORK ADD ArachnoIRC
/SERVER ADD -auto -tls -tls_verify -network ArachnoIRC irc.arachnogoat.com 6697 SERVER_PASSWORD
/CHANNEL ADD -auto #chat ArachnoIRC
/SAVE

/SAVE writes the supplied server password to the user's Irssi configuration. Protect that account and its ~/.irssi directory accordingly.

DETACH AND RETURN

Leave Irssi running

Press Ctrl-b, release both keys, then press d. tmux detaches while Irssi and its IRC connection continue running.

Resume the session

After reconnecting through SSH, attach to the existing tmux session from the shell.

Resume the IRC tmux sessionraw
tmux attach -t irc

SEE ALSO

See tmux(1) and the Irssi built-in /HELP command for additional session, window, and connection controls.

Message sent

X

Thank you. Your message has been sent.