Unraid Plugin

August 5, 2026 · View on GitHub

unraid/ packages labby as a classic Unraid webGUI plugin (.plg) — a native, rc.d-managed process by default, with an optional Incus-backed mode for stdio MCP workloads. No Docker, no systemd on the host (Unraid does not run one). This is a separate deployment target from INCUS.md (recommended self-hosted gateway runtime) and the Docker Compose stack (docker-compose.prod.yml); pick whichever fits the host, they are not mutually exclusive.

Why native instead of Docker

Unraid has first-class Docker support, and the released image (ghcr.io/dinglebear-ai/labby) already runs there. The .plg path exists because "installable as an Unraid plugin" specifically means the classic .plg/Plugins-tab mechanism, and because labby is a single, dynamically linked (glibc) binary with no bundled shared libraries and its own embedded admin web UI — it does not need container isolation or a companion frontend service the way a plugin like ~/workspace/incus-unraid (packaging Incus, a system-container manager with real host-library dependencies) does.

Empirically verified on real hardware (Unraid 7.3.1, glibc 2.43): the lab-x86_64-unknown-linux-gnu.tar.gz release binary runs unmodified — ldd resolves cleanly (libc, libgcc_s, libm only) and labby serve fully bootstraps. The binary's max required symbol version is GLIBC_2.39, well under Unraid's 2.43. No musl/static build is needed for this to work. (This was a one-off binary-compatibility check on a production NAS, done before plugin development started. All later end-to-end plugin testing — install/start/stop/settings-form/OAuth-login flows — moved to a dedicated test box, tower (Unraid 7.3.2); see "Validated end-to-end" below.)

Two runtime modes

labby.cfg's RUNTIME_MODE selects how the gateway actually runs:

  • native (default) — the bare rc.d process described above. It has no dependency on Incus and is the lowest-risk install path, but it cannot run most stdio MCP servers: Unraid's bare host ships neither npx/Node nor uv/Python, which is what most community MCP servers are distributed as. Use this mode for the core gateway API, registry, and Code Mode surfaces that do not need a host toolchain.
  • incus — runs labby inside an Incus system container using labby's own pre-built labby-incus release image. The image bakes in Node, uv-managed Python, Rust, Go, the agent CLIs, and Tailscale; see INCUS.md for the full toolchain floor. This is the capability-complete mode for real stdio MCP server workloads.

incus mode has a hard dependency: the ~/workspace/incus-unraid plugin must already be installed, enabled (SERVICE=enabled in its own incus.cfg), and running after the array starts. This plugin does not bundle a second Incus daemon. scripts/labby-incus-env.sh points the Incus CLI at incus-unraid's private prefix (/usr/local/incus) and daemon state (INCUS_DIR=/mnt/user/appdata/incus), and every Incus operation in scripts/labby-incus-init.sh goes through that environment.

The Incus container layout is intentionally separate from incus-unraid's own agent-jail defaults. labby-incus-init.sh creates a dedicated labby-dir storage pool, a dedicated labby-gateway profile, and a dedicated bridge named by INCUS_BRIDGE_NAME (labbybr0 by default). It never touches incus-unraid's agentbr0, its default profile, its storage pool, or its daemon lifecycle, and the labby container launches with only the dedicated profile. The bridge CIDR comes from INCUS_BRIDGE_SUBNET (10.99.99.1/24 by default) and is checked for host-route collisions before creation. If the bridge already exists but is not Incus-managed, startup fails closed instead of deleting or reusing an unmanaged host interface.

Bridge egress is explicit but deliberately narrow. INCUS_EGRESS_POLICY="block-lan" is the default and installs verified host FORWARD rejects for traffic entering from the Incus bridge and targeting private IPv4/Cgnat ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10). This blocks bridge-forwarded private destinations over eth0; it is not a full tailnet or in-container firewall policy. Tailscale traffic originates inside the container, so use a tagged Tailscale auth key and tailnet ACLs for tailnet egress control. Set INCUS_EGRESS_POLICY="allow-lan" to remove those reject rules and allow normal bridge-forwarded NAT egress from the container. The plugin also removes the rules it owns when Incus mode stops or the plugin is uninstalled.

Reachability for incus mode is via Tailscale running inside the container, not host port forwarding. INCUS_TS_AUTHKEY is a write-only, one-shot Settings > Labby field: the value is never echoed back into the form, is written into the container only as a mode-0600 temporary file, is removed after tailscale up, and is cleared/redacted from both labby.cfg and labby.cfg.bak after an attempted consumption. Leaving the password field blank when saving also clears any previously stored key, which is how to recover from an expired or failed preauth key.

The Incus image pin is independent of the plugin package version and the bundled native labbyVersion. INCUS_IMAGE_VERSION defaults to "1.2.0" because that tag currently publishes labby-incus-x86_64-unknown-linux-gnu.tar.xz; v1.3.0 does not. The known-good v1.2.0 SHA256 from the published .sha256 asset is:

dfb57f59b52a84db5b14ac71588b676d7135d4b24916628006aaaed8f022c25d

INCUS_IMAGE_SHA256 must be set to the digest for the configured INCUS_IMAGE_VERSION before incus mode can start. Cached image bytes live under ${LABBY_DIR}/incus-images, include the image version in the filename, and are verified against the configured SHA256 before import; corrupt caches are removed and redownloaded once, then fail closed if verification still does not match. Imported Incus images and launched containers are stamped with the configured image version and SHA256; if an existing alias or container does not match the configured pin, startup fails with explicit delete/recreate guidance instead of silently reusing stale runtime bytes.

Known release gap: the labby-incus-x86_64-unknown-linux-gnu.tar.xz release asset has not published successfully since v1.2.0 (gh release view v1.3.0 --repo dinglebear-ai/labby --json assets shows only the plain binary archives and SHA256SUMS). Confirm the latest tag before assuming a newer INCUS_IMAGE_VERSION will work, and bump both INCUS_IMAGE_VERSION and INCUS_IMAGE_SHA256 together once the release-asset CI gap is fixed.

Layout

icon.svg                                       repo-root brand mark (dinglebear-ai mark system) — the CA repository icon
                                               ca_profile.xml points at, and the vector source for both PNGs below
ca_profile.xml                                 repo-root Community Applications repository profile
unraid/
  labby.plg                                    plugin manifest (installed via Unraid's Plugins tab)
  ca/labby.xml                                 Community Applications plugin template (see "Community Applications" below)
  source/usr/local/emhttp/plugins/labby/
    labby.cfg                                  default config template (flash-persisted copy is the source of truth once installed)
    images/labby.png                            460px brand avatar — Plugins page (PLUGIN icon attr), Settings panel grid (Icon=), and the CA <Icon> URL
    icons/labby.png                             64px favicon cut — webGUI tab_title() renders Tag=".png" from icons/ at 18px
    Labby.page                                  status + settings form (SERVICE/LABBY_DIR/HTTP_HOST/HTTP_PORT/RUNTIME_MODE/INCUS_IMAGE_SHA256/...) plus native gateway reload/upstream controls
    LabbyDashboard.page                         dependency-free Main/Dashboard tile for live aggregate gateway health
    include/dashboard-status.php                authenticated native/Incus status endpoint used only by the dashboard tile
    scripts/rc.labby                            start/stop/restart/status, branches on RUNTIME_MODE between the native rc.d path and the Incus container path
    scripts/labby-preflight.sh                   read-only glibc/binary sanity check for native mode; rc.labby refuses to start if this fails
    scripts/labby-incus-env.sh                   points the Incus CLI at incus-unraid's private-prefixed daemon — incus mode only
    scripts/labby-incus-init.sh                  idempotent Incus-mode converger: storage pool, bridge, egress policy, profile, image import, container launch, in-container provisioning — incus mode only
    incus/labby-gateway-profile.yaml             vendored copy of config/incus/labby-gateway-profile.yaml
    event/disks_mounted                          array-start hook — calls rc.labby start, which is RUNTIME_MODE-aware
    event/unmounting_disks                       array-stop hook — calls rc.labby stop, which is RUNTIME_MODE-aware

labby.plg does not bundle a .txz/Slackware package the way incus-unraid does — that exists there because Incus ships multiple binaries plus host libraries Unraid doesn't provide. labby is one binary with no extra libs, so the .plg downloads the existing GitHub Release tarball directly (the same asset scripts/install.sh consumes) plus each small companion file under source/, each pinned by its own <MD5> entity.

Persistence model (Unraid boots into RAM from a flash drive)

  • Persistent config: /boot/config/plugins/labby/labby.cfg (flash). Seeded once at install, never overwritten if already present. Edit SERVICE=enabled here to autostart on array start.
  • One-shot Incus Tailscale key: /boot/config/plugins/labby/incus-ts-authkey (flash, best-effort mode 0600). The settings page writes it separately from the bash-sourceable config; labby-incus-init.sh consumes it, deletes it, and redacts any legacy INCUS_TS_AUTHKEY value from labby.cfg and labby.cfg.bak after every attempted use.
  • Runtime OS files: /usr/local/emhttp/plugins/labby/* (RAM). Rebuilt fresh from the flash-cached tarball + source/ files on every boot.
  • Gateway state (auth.db, registry.db, config.toml, the MCP bearer token — everything labby normally writes under $HOME/XDG dirs): LABBY_DIR in labby.cfg, default /mnt/user/appdata/labby (array, survives reboots — the same convention every Unraid Docker app's appdata mount already uses). rc.labby exports HOME/XDG_* to point there instead of root's RAM-only /root.

Settings page conventions

Labby.page is the complete Labby-for-Unraid control plane, rendered directly inside Settings > Labby. Its Overview, Gateway, and Settings tabs match the supplied Labby-for-Unraid design while remaining a native .page; there is no iframe, separate route, mock dataset, or standalone settings application. Status cards and counts come from rc.labby plus the live gateway catalog. Gateway controls expose reload, filter, add-HTTP/add-stdio, enable/disable, remove, and stale-process cleanup actions. Settings owns SERVICE, LABBY_DIR, HTTP_HOST, HTTP_PORT, RUNTIME_MODE, and all Incus image/network/Tailscale fields from labby.cfg, with backup-first atomic persistence.

LabbyDashboard.page adds a native Main > Dashboard tile. It polls include/dashboard-status.php every 15 seconds for service/runtime state, connected versus enabled upstream counts, exposed tool totals, and connection errors. The endpoint is served behind the normal webGUI session and returns aggregates only: upstream names, URLs, commands, environment keys, credentials, logs, and tool arguments are never included. Set DASHBOARD_WIDGET_ENABLE="false" in Settings to hide the tile without disabling the gateway.

In native mode gateway actions run the host plugin binary against LABBY_DIR; in Incus mode the same actions execute labby --json gateway ... inside the gateway container as the labby user so they operate on the live container-owned gateway state. The visual shell uses the same role patterns and theme contract as the components under ~/workspace/upstream/unraid-api/unraid-ui, adapted to the classic Unraid PHP/Markdown runtime that actually hosts third-party plugin pages. The markup conventions were reverse-engineered from a live Unraid 7.3.x install's own pages (/usr/local/emhttp/webGui/DateTime.page, dynamix.my.servers/Connect.page) and cross-checked against ~/workspace/upstream/unraid-api (which shares the same theme-token contract with the classic webGUI via Theme--white/black/gray/azure, even though its Tailwind/Vue tokens aren't directly usable from a classic .page):

  • Fields: the _(Label)_: / : <input> markdown definition-list idiom on a <form markdown="1"> — Unraid's own page renderer (PageBuilder.php/MainContent.php) runs the whole page body through Markdown(), which turns this into a real <dl>. No custom wrapper divs.
  • Selects: mk_option($current, $value, $label) (a core webGUI global, loaded unconditionally via webGui/template.php) — used for every enum-valued field (SERVICE, HTTP_HOST, RUNTIME_MODE, INCUS_EGRESS_POLICY), matching how every real first-party page handles booleans/enums (no switchbutton widget; that's a real but unconfirmed-markup asset pulled from a separate unraid/webgui repo at dev time, not worth the risk of hand-rolling incorrectly).
  • Help text: <blockquote class="inline_help"> immediately after a field's dt/dd — wired automatically to the toolbar's "?" help toggle by core JS (DefaultPageLayout.php) that scans for this class, no extra markup needed on the label side.
  • Status color: .green-text/.red-text (core classes backed by --green-800/--red-600), not hand-rolled hex values.
  • Buttons: bare <input type="submit"> — no .btn-style class exists in any genuine first-party .page; the core stylesheet styles submit/ button inputs automatically.
  • Icon: Icon="labby.png" + Tag="labby.png" — the real Labby brand mark (dinglebear-ai mark system, generated from the repo-root icon.svg). Verified against a live 7.3.x install's core code: these are two different resolution paths. Icon=".png" (Settings panel grid, via MainContent.php's process_icon()) and the plugin manager's icon attribute (ShowPlugins.php) both resolve from plugins/labby/images/, while Tag=".png" (the 18px tab-title icon, via PageBuilder.php's tab_title()) resolves from plugins/labby/icons/. Hence two shipped assets: images/labby.png (460px avatar, dark background) and icons/labby.png (64px transparent favicon cut — the brand system's designated below-20px variant). A bare FontAwesome name (the pre-1.4.0 Icon="server" interim) remains the fallback pattern if the PNGs are ever missing.

Persistence stays custom, deliberately: settings do not go through Unraid's generic Dispatcher.php (POST /update.htm with a #cfg field), even though that's the more "native" save mechanism for classic pages. Dispatcher.php requires an INI [section]-headed config file (parse_ini_file($file, true)), but labby.cfg is a flat, [section]-free KEY="value" file so rc.labby/the event hooks can keep bash-source-ing it unmodified. Labby.page POSTs back to itself instead and regenerates the whole file from a template, preserving the explanatory comments.

Because rc.labby bash-sources labby.cfg verbatim, every field is validated server-side against a strict allowlist before being written — an unvalidated value containing a ", $, backtick, or newline would be interpreted as shell syntax the next time the file is sourced, not just a bad config value. SERVICE/HTTP_HOST are checked against an exact-match enum (not just constrained by the <select>, which a crafted raw POST can bypass), RUNTIME_MODE and INCUS_EGRESS_POLICY against exact enums, INCUS_CONTAINER_NAME against a DNS-label pattern, INCUS_IMAGE_VERSION against plain X.Y.Z, INCUS_IMAGE_SHA256 against a lowercase 64-character hex digest when provided, INCUS_TS_AUTHKEY against a short token-safe character set, INCUS_BRIDGE_SUBNET against IPv4 CIDR syntax, HTTP_PORT against a numeric 1–65535 range, and LABBY_DIR against a path-character allowlist (^/[A-Za-z0-9_./-]+$) with no shell metacharacters permitted at all.

Two version numbers, on purpose

labby.plg tracks two independent versions:

  • version — the plugin package's own version, shown in Unraid's Plugins page and used for its install/update comparison. Bumped whenever unraid/ packaging itself changes, even if labby's binary hasn't (e.g. the 1.3.0a bump that shipped the HTTP_HOST default fix below).
  • labbyVersion — the labby release tag this plugin currently bundles. Only this entity drives tarballURL/tarballMD5. Keeping it separate from version means a packaging-only fix never has to point at a labby release tag whose binary asset doesn't exist (or force a new labby release just to ship a plugin bugfix).

Keeping the .plg in sync with releases

Every <MD5> in labby.plg, plus labbyVersion, must match what's actually published, or Unraid's install/update either 404s or fails checksum verification. scripts/ci/unraid-plugin-checksums.sh is the single source of truth for this — it checks (default) or rewrites (--fix) every entity:

scripts/ci/unraid-plugin-checksums.sh                                   # check only
scripts/ci/unraid-plugin-checksums.sh --fix                             # repair after editing unraid/source/
scripts/ci/unraid-plugin-checksums.sh --tag vX.Y.Z --tarball PATH       # also check labbyVersion + release tarball MD5
  • ci.yml's always-on unraid-plugin-check job runs the no-args form on every push/PR, so editing unraid/source/ without running --fix afterward fails CI immediately (this is exactly the failure mode that motivated the script — see git history for the Labby.page checksum drift caught and fixed during initial scaffolding).
  • The --tag/--tarball form is a manual tool, not wired into any CI job. An earlier version of this PR ran it automatically from release.yml against ${{ github.ref_name }} (the tag currently being released) — that was wrong on two counts, caught in review: labbyVersion intentionally pins to a specific, already-published, manually-vetted labby release (see "Two version numbers" above), not whatever tag is currently being built, so comparing it to github.ref_name would fail on every release where they legitimately differ (which is the normal case); and even if they matched, a freshly-built release tarball's MD5 is not reproducible build-to-build — GNU tar embeds each packaged file's mtime, so byte-identical binary content still produces a different archive hash on every CI run, making an automatic same-run comparison impossible to ever pass. Run this form by hand instead, against a tarball downloaded from the already-published release you're pointing labbyVersion at, whenever you deliberately bump it:
    gh release download vX.Y.Z --repo dinglebear-ai/labby -p "lab-x86_64-unknown-linux-gnu.tar.gz"
    scripts/ci/unraid-plugin-checksums.sh --tag vX.Y.Z --tarball lab-x86_64-unknown-linux-gnu.tar.gz --fix
    
  • The unraid-plugin-check CI job does not auto-commit a fix; a mismatch fails the run and the fix must be applied locally (--fix) and committed like any other change.
  • The version entity itself has no automated check — it's a plugin-package concern bumped by hand, the same way incus-unraid hand-bumps its own .plg version per content change, decoupled from any upstream Incus version.

Required step: tag every commit that touches labby.plg or unraid/source/

srcURL (all thirteen companion-file downloads: labby.cfg, Labby.page, LabbyDashboard.page, its aggregate status endpoint, rc.labby, labby-preflight.sh, labby-incus-env.sh, labby-incus-init.sh, the vendored Incus profile, both event hooks, and both brand PNGs) is pinned to an immutable tag — unraid-v&version; — not to main. This is deliberate: every file under srcURL is MD5-verified against a value baked into whatever version is cached on flash, and Unraid's classic .plg model re-downloads and re-verifies every <FILE> on every boot, since /usr/local/emhttp is tmpfs and gets wiped on reboot. If srcURL pointed at main (as it did before 1.3.0e), any later commit touching these files — even for a totally unrelated packaging round — would break every already-installed copy's next boot with an MD5 mismatch, without version ever having changed for that install. pluginURL (the manifest URL Unraid's plugin manager polls to detect updates) deliberately stays on main — that one must always resolve to the latest content, or update detection would freeze.

After committing any change to labby.plg or unraid/source/:

git tag unraid-v<version>   # e.g. unraid-v1.3.0e, matching the version entity
git push origin unraid-v<version>

This has no automated check — the tag legitimately can't exist until after the commit it points at is pushed, so scripts/ci/unraid-plugin-checksums.sh cannot verify it in the same CI run. Forgetting this step doesn't break the commit you just made (fresh installs and installs already on main's current state at push time still resolve srcURL correctly), but it does mean the NEXT commit that touches these files will retroactively break any install that adopted the untagged version — tag every round, not just when something feels risky.

Community Applications

Two files describe this repo to Community Applications, and CA reads both from main — neither is covered by the srcRef tag pinning above, because CA is not the plugin's own boot-time download path:

  • ca_profile.xml (repo root, required there — CA looks for it at the root of a submitted repository, not in a subdirectory) — the repository profile: one-line overview, project page, and the repo icon. Its <Icon> points at the repo-root icon.svg.

  • unraid/ca/labby.xml — the plugin entry. Its <PluginURL> must match labby.plg's pluginURL entity byte-for-byte, or the Apps-page install button breaks silently. Both are https://github.com/dinglebear-ai/labby/raw/main/unraid/labby.plg.

    "Byte-for-byte" is not pedantry. GitHub serves the identical .plg from both github.com/OWNER/REPO/raw/REF/PATH and raw.githubusercontent.com/OWNER/REPO/REF/PATH, so a mismatch here is invisible to every check that merely fetches the URL — but CA compares the two as strings. labby.xml used the raw.githubusercontent.com form against the .plg's github.com/.../raw/ form right up until 1.4.0. scripts/ci/unraid-plugin-checksums.sh now expands the .plg's entity chain (pluginURLrawURLrepoURL) and string-compares the result, failing the unraid-plugin-check CI job on any drift. It deliberately has no --fix for this one: which side is wrong depends on whether the repo moved or the CA entry went stale, and that is a call a human has to make.

The Unraid version floor lives on labby.plg's PLUGIN tag as min="7.0.0", not as <MinVer> in labby.xml — CA gives the .plg attribute precedence and ignores the XML element when both exist, and the attribute additionally covers direct Install-Plugin-URL installs, which CA metadata never sees. dynamix.plugin.manager/scripts/plugin checks it with version_compare and aborts with "installed Unraid version is too low, require at least version 7.0.0".

The number is not arbitrary: the release binary's highest required symbol version is GLIBC_2.39 (confirmed with objdump -T), and Unraid 7.0.0 is the first release shipping a new enough glibc — 7.0.0 has 2.40, 7.2.0 has 2.42, 7.3.x has 2.43. Unraid 6.x is below the floor and would otherwise fail labby-preflight.sh at start time rather than install time. Revisit this if the release build's glibc floor ever moves.

<Support> in labby.xml is redundant with the support attribute now set on labby.plg's PLUGIN tag — per the CA starter template, the .plg attribute wins and the XML element is ignored when both exist. Both are set to the same issues URL, so they cannot disagree.

Submission itself is a manual, one-time step and has not been done yet: push the repo, then run Validate and Scan in the Community Apps submit flow (/submit) per the starter template. CA requires an OSI-approved license in the repo. LICENSE (AGPL-3.0-only, adopted in #346 alongside separate commercial terms) satisfies this, and labby.xml declares <License>AGPL-3.0-only</License> to match. Keep that element in sync with Cargo.toml's license field and LICENSING.md — it is a public claim about how the listing is distributed, and nothing checks it automatically. Do not advertise the commercial option in that element; CA's field describes the open-source license only.

Known gaps

  • Community Applications submission has not been run — the templates are in place and valid, but until the /submit Validate+Scan flow is completed, install via the Plugins tab's "Install Plugin" URL field pointed at the raw labby.plg URL.
  • RUNTIME_MODE="incus" now has its architecture, image/version pinning, Tailscale behavior, bridge, and egress defaults wired into the plugin package, but the Incus path still depends on a known release-asset CI gap: only v1.2.0 currently publishes the labby-incus-*.tar.xz image asset. Newer Incus images require fixing that CI path first, then bumping both INCUS_IMAGE_VERSION and INCUS_IMAGE_SHA256. Tracked as lab-26zqj.
  • RUNTIME_MODE="incus" has not yet been exercised across a real Unraid reboot or a real incus-unraid uninstall/reinstall cycle. The current implementation is designed for array-start/stop and plugin install/update/uninstall of the labby plugin itself; full lifecycle validation is still pending.
  • Validated end-to-end on real hardware (tower, Unraid 7.3.2) via Unraid's actual plugin command: fresh install (checksum-verified download of every file), rc.labby start/status/ready/stop, plugin remove labby.plg uninstall (state correctly preserved), a real version-bump update cycle (plugin check/plugin update), the settings form's save-and-regenerate path (including injection-resistance testing), and a real Google OAuth login through the web UI (via a Tailscale Serve HTTPS front). Not yet tested: the event/disks_mounted and event/unmounting_disks hooks via a real array stop/start (only rc.labby start/stop have been invoked directly), and reboot persistence (the RAM-boot rebuild-from-flash-cache behavior has never actually been observed across a real reboot).