Repository navigation
v15.0.0 (2026-10-10) - "Local First"
Design 0001, phases 0 to 3: every node keeps its own database and keeps working on its own; paired nodes exchange configuration (with secrets) and merge it per resource; high availability is rebuilt on Corosync quorum, a third vote from anything, and storage groups that move between nodes with their shares, apps and a floating address.
Upgrade notes (breaking)
- Patroni-based HA is removed.
services.dplaneos.ha(Patroni, etcd, HAProxy, keepalived), the etcd witness and the installer's "Install Witness Node" entry are gone. Removeservices.dplaneos.ha.*from your NixOS configuration if you set it there. HA is set up anew from the web interface: pair the nodes (System › Configuration Sync), form the cluster and add a third vote (System › High Availability), create storage groups. See HIGH-AVAILABILITY.md. - Removed API endpoints: the legacy
/api/ha/*routes for peers, promote, switchover, fence, maintenance, witness, network witness, SBD, cluster secret, replication, timing, toggle, heartbeat, standby and ALUA standby. Kept:/api/ha/protection,/api/ha/watchdog/configure,/api/ha/fencing/configure,/api/ha/pdu/configure,/api/ha/hardware/detect,/api/ha/scsi/*,/api/ha/ctdb/*. - Removed daemon flags:
--ha-local-id,--ha-local-addr,--ha-cluster-secret. - CTDB takes its node list from the new
services.dplaneos.ctdb.nodes. - Prometheus: the HA metrics changed (
dplaneos_ha_*,dplaneos_group_*); update dashboards and use the newprometheus/ha-alerts.yml. - The database schema migrates automatically on first start (migrations up to 00027).
API and web interface: the API handlers run in CI against a real PostgreSQL
schema and a command fake, and CI checks every request and response of the
web interface against its handler (scripts/check-api-contract.py). What
that found is listed here.
Changed
- API token scopes are enforced. A
readtoken can only read, a
writetoken cannot perform admin actions. - API tokens expire on their expiry date.
- New NFS exports default to
root_squash. - The file manager, shares, exports, ACLs, backups and public file links
work on pool and removable-media mounts only (/mnt,/tank,/data,
/mediaand every mounted ZFS filesystem outside the system
directories). - Accounts get their permissions from RBAC roles; the built-in roles
(admin, operator, user, viewer) have default permissions. - Scheduled SMART tests use systemd calendar expressions.
Security
-
Rsync backups: paths only had to start with /mnt/ ("/mnt/../etc/shadow"
as source, /etc as destination by way of a symlink). Source and
destination are resolved against the pool and media mounts before
every run; the remote shell is fixed. -
Certificates: the Let's Encrypt request used an unchecked certificate
name as a file path (files could be written anywhere as root), and
activation wrote an unchecked name into the nginx configuration. -
The CSRF check was skipped when the session lookup failed (database
slow or down); such requests are refused now. -
Public file links: a link could be created for any file on the system
(/etc/shadow, the daemon's secrets key) and downloaded without a login.
Only files on pool and media mounts can be linked, and the path is
checked again at download. -
Trash: a "/mnt/../etc/..." path moved system files into the trash;
restoring trusted the item name (path traversal) and an original
location read from a file on the share, so a share user could have the
daemon move a file anywhere as root. Paths are resolved and checked, and
trash folders must be owned by the daemon. -
POSIX and NFSv4 ACLs, cloud sync folders: "/mnt/../etc" passed the prefix
check and symlinks were followed; the real path is checked now. -
SMB shares and NFS exports accepted any folder (/, /etc); with
no_root_squash an export of / is root on the machine. Shares must be
folders on pool or media mounts, given by their real path, and are
checked again whenever the Samba and exports files are written. Updating
a share did not check the path at all. -
File manager (runs as root; the built-in "user" role may use it): listing
and file properties had no path check at all, the other operations
checked paths lexically only (a symlink created on a share reached the
whole system), the allowed roots included /home, /tmp and the daemon's
data directory (secrets key, SSH keys), and setuid/setgid modes were
accepted. It is now limited to pool and removable-media mounts, every
path is checked after resolving symlinks, deleting or renaming a symlink
acts on the link itself, writes never follow a symlink, and
setuid/setgid modes are refused. -
First-run setup: /api/system/setup-admin replaced the admin password
even when the installer had set one, so anyone on the network could take
the account over until setup was finished; /api/system/setup-complete
needed no login at all (and set the hostname). The admin password is
never overwritten now, and finishing setup needs the admin session. -
Password resets and account creation ignored the role hierarchy: a
non-admin user manager could reset an admin's password or create an
admin. Nobody can raise their own role. -
The 2FA step had no attempt limit (5 failures in 15 minutes now lock it
per user); backup codes could be used twice concurrently; TOTP codes
could be replayed within their window. -
Dataset names starting with
-were accepted and read as options by
zfs (a "dataset" named-aunloaded every encryption key). -
iSCSI targets accepted any device (the system disk could be exported);
only zvols are accepted. Targets whose authentication setup failed were
left running; CHAP failures left unauthenticated ACLs. Both roll back. -
Remote replication passed base_snapshot, rate limit, key path, host and
user to zfs/pv/ssh with only shell characters filtered: a value starting
with "-" became an option (ssh -oProxyCommand runs a command as root).
All are validated now, for peers too. -
The login lockout overflowed after about 35 failures and stopped
throttling. -
Malformed token allowlists are refused instead of treated as
unrestricted. -
SSH keys: a key label with a line break added arbitrary
authorized_keys entries; key lines were not really parsed (options,
extra lines, bad key data); and a user could point ~/.ssh at another
account's directory to have the daemon (root) write that account's
authorized_keys. The file is now written without following symlinks.
Fixed (API and web interface)
- Storage groups: during a planned move the old owner could take the
floating address (and start the group's apps) again while the new owner
was still importing the pool, and kept the address until its next
check. An activation pass that overlaps a move now leaves the group
alone and undoes what it had just taken. - Rsync backups: one-off backups ignored the options entered; pools
mounted outside /mnt were refused;--exclude=patterns, shown as an
example, were refused; a remote backup could hang on a password prompt
(ssh now runs in batch mode). - The disk latency check called every healthy disk "slow" (it timed a
fixed three-second read test) and found no disks in pools imported by
id. It reports the measured read speed now and tests the disks at the
same time. Pool I/O statistics lacked write throughput and wait times.
Both are on the Pools page. - Directory services: additional Active Directory domains (register,
join, leave, check) are in the UI; overlapping ID ranges and duplicate
names are refused. - Let's Encrypt certificates were saved as .pem while the list,
activation and deletion only knew .crt: a certificate obtained in
the UI never appeared and could not be activated. Nothing renewed them
either: renewal ran only when its API was called, looked for the wrong
files and ended its job after the first certificate. Certificates are
now checked twice a day and renewed 30 days before they expire (the web
server reloads them); "Renew due certificates" runs the check at once. - Git repositories with auto sync: the last sync time was read back in
the database's time zone but treated as UTC, so east of UTC a
repository was not synced again for hours. The same mislabelling made
API token, user and group timestamps wrong by the zone offset. - Removed the single-repository git sync endpoints (/api/git-sync/config,
pull, status, stacks, deploy, export, push) and its table; repositories
are managed under /api/git-sync/repos. - NixOS apply: the "confirm or roll back" banner lived only in the open
page (a reload lost it and the system rolled back unconfirmed), counted
60 s while the server waited 120 s, and its "Rollback" button only hid
it. The banner now follows the server's timer. - Settings: restoring a database backup from the UI always failed (no
CSRF token was sent). Memory tuning (ZFS ARC limit, swappiness) is in
the UI; it reported "8 GB" when nothing had been set and carried six
settings that did nothing. Stored-secrets check, NixOS config backup to
Git and audit log retention are in the UI; the audit chain no longer
shows "Valid" when the check could not run. - Removed /api/smb/vfs, which only returned text to paste into smb.conf
(shares have Time Machine, shadow copy and recycle bin options). - Scheduled SMART tests never worked from the UI: the disk name was sent
without /dev/ and refused, a schedule was only written to the GitOps
state file (an error without one, and nothing ran until a manual
apply), the list dropped every row, and schedules were cron
expressions handed to systemd timers. Schedules are stored, turned into
timers at once and written back to the GitOps state; they are systemd
calendar expressions. Disks named by /dev/disk/by-id work, and a test
can be started right away ("Run test now"). - Hardware page: drive enclosures (bays, the disk in each, fault state)
with locate LEDs. Pools page: fill level and the emergency reserve
(reserve 2%, release when the pool is full). - Trash: everything moved to the trash was copied to one folder on the
system disk (/mnt/.dplaneos-trash). Each filesystem now has its own
trash folder, so trashing is a rename and uses no extra space. - Pools mounted at / (created or imported without a mountpoint):
the file manager, ACLs and shares only knew /mnt, /tank, /data and
/media. Every mounted ZFS filesystem outside the system directories is
available now; creating folders under /media was refused. - Files page: Move and Properties added; copy, rename, delete, trash and
permission changes reported success when the server had refused them. - Docker: updating a container pulled the new image but only restarted
the old container, which kept running the old image while the update
reported success. It now recreates the container with the same
configuration and restores the previous one if the new one does not
come up healthy; compose containers are updated with their stack. - Docker: system prune also removed every stopped container and then the
volumes they left behind (a stopped app lost its data). It now removes
dangling images; stopped containers and unused volumes are separate
opt-ins. Removing an image from the API always failed (the ID was read
from the wrong place). - Docker page: images (list, remove), live container stats, pre-flight
checks, container updates and "docker run" import are now in the UI. - Files: the Upload button sent the file's path as the target folder, so
uploads failed (drag and drop worked). Custom app icons get an upload in
the Docker page; icon files are served with a sandboxing CSP. - Replicating a dataset without naming a snapshot could pick the newest
snapshot of a child dataset and replicate that instead. - Changing an encryption key failed on unlocked datasets and never checked
the old passphrase. - Shrinking a zvol (which destroys data) needs
allow_shrink. - NFS changes said "applied" when exportfs failed; iSCSI said "created"
when saving the configuration failed. - The iSCSI page could not create or update targets (field mismatch) and
crashed once a zvol existed. - Concurrent storage operations on one target could both start
(migration 00024 enforces one pending operation per target). - A double-submitted 2FA login returned a session id that did not exist.
- The setup wizard could not list disks or create a pool (both need a
session it never had). It logs in after creating the admin account, or
asks for the password chosen during installation. - User and group management in the web interface did not work: the API
requires the signed-in admin's current password for these changes and
the Users page never sent it; groups were also sent by name with member
names, which the API did not accept. The page asks for the password,
and the API accepts names. Renaming a group failed on its memberships
(migration 00025). - Web pages that sent fields their API does not read (found by comparing
every request of the web interface with its handler):- Pools: unlocking/locking encrypted datasets, clearing errors and
onlining disks failed. - iSCSI: ACLs could not be added, removed or listed; the target and ACL
lists were always empty (targetcli output was misparsed). - Removable media: mount, unmount and eject failed.
- UPS: the page edited NUT connection settings the API does not have and
saved defaults instead of the chosen shutdown level. It now edits the
shutdown policy (action, battery level, grace period). - GitOps: repository pull/push/deploy/delete, credentials (save, delete,
test) and the setup wizard failed; saving a repository disabled it. - Directory: the LDAP form did not load or save the server and TLS
settings; group mappings could not be added or removed.
- Pools: unlocking/locking encrypted datasets, clearing errors and
- SMTP alerts could not be saved from the web interface (the page had no
recipients field and the API requires one), the saved settings did not
load, and the "enabled" switch did nothing. NFS exports created switched
off were exported anyway. - LDAP group mappings never applied and LDAP users got no permissions:
sync wrote only the cosmetic users.role. Mappings now name RBAC roles,
sync assigns them (and revokes them when a user leaves the group; roles
an admin granted are kept). Migration 00026 converts existing mappings. - Webhook body templates inserted values unescaped: a message with a quote
produced invalid JSON and the alert was rejected. Webhooks cannot
target link-local addresses (cloud metadata).
Phase 0: fixes in GitOps and HA that do not depend on the new architecture.
Fixed
- GitOps write-back could revert other people's commits. A web UI change snapshots the live state and commits it. It now fetches first and refuses to commit while the repository holds commits not yet applied to this system (it would have silently reverted them); the UI change stays live and is committed with the next write-back. The commit the live system corresponds to is tracked (
synced_commit) and advances on push and on fully successful applies. - Failed rebases and pushes were only logged. A failed
git pull --rebaseis aborted and returned instead of being ignored; a missing remote branch is told apart from a network or authentication error; write-back failures appear on the GitOps page (last_commit) and as agitops.commit_failednotification. - Authenticated Git never worked from the daemon.
RunInDirWithEnvreplaced the whole environment with the credential variables, so git ran withoutPATHandHOME: the token askpass helper could not runcatandGIT_SSH_COMMANDcould not findssh. The credentials now extend the environment. - The drift detector never fetched. It compared against whatever the local clone held. It now fetches and fast-forwards the clone every 5 minutes on every node; local snapshot commits that diverged are reset to the remote (they are regenerated from live state). A second, duplicate drift detector started in
mainis removed. - Secrets key handling. New
services.dplaneos.secrets.keyFile/fallbackKeyFile(-secrets-key,-secrets-key-fallback): every sealed value is checked at startup, values only the fallback key opens are re-sealed under the active key, and undecryptable values are reported (GET /api/system/secrets/status, log). Rotation now preserves unknown fields in the SMTP settings. - Plain-text secrets in
state.yaml.ldap.bind_passwordis rejected (apply stored it unencrypted in a column read as encrypted, breaking LDAP binds);password_hashmust be a bcrypt hash. - GitOps page status card always showed "Synchronized": it read fields the API does not return. It now shows drift, check time, the synced commit, drift-check errors and failed write-backs.
Added (Design 0001, Phase 1)
- Change History (System → Change History,
/api/config/*): every change to datasets, shares, NFS exports, users, groups, stacks, replication, SMART tasks, LDAP/ACME/certificate settings and system settings is recorded as a revision, right after a web UI change and every 5 minutes for changes made elsewhere. Each entry shows the settings before and after; secrets are stored only as fingerprints. A change can be undone ("Undo this change": back to the previous version of that resource) through the GitOps engine (BLOCKED safety rules apply), and the current configuration can be exported asstate.yamlwithout a Git repository. Revisions are stored in the newconfig_revisionstable.
Added (Design 0001, Phase 2)
- Configuration Sync between nodes (System → Configuration Sync,
/api/config/sync/*,/api/config/peers/*,/api/config/conflicts): nodes with their own databases are paired with a single-use join code after a merge preview, then exchange revisions of datasets, shares, NFS exports, users, groups, replication jobs and shared settings (pull every 30 s and on change; TLS certificate pinned at pairing). Changes are merged per resource with a three-way merge over revision ancestry: changes to different resources merge automatically and are applied through the GitOps engine (BLOCKED rules unchanged); the same resource changed on two nodes becomes a conflict shown with both versions (keep, take the other's, or edit a combined version), never resolved silently. Node states: standalone, connected, isolated (banner on every page with the number of changes waiting) and independent (after Detach). Received changes for a pool that is not imported wait for it. - The change history no longer records datasets of an exported pool as deleted.
- The change history splits the system section into node settings (hostname, network interfaces) and shared settings (time zone, DNS, NTP, firewall ports, Samba globals, SSH); shared settings are exchanged between paired nodes and can be rolled back. Docker stacks whose volumes are on a pool, and NVMe-oF exports, are recorded with their pool instead of the node (recorded, not yet exchanged: they must run only on the pool owner). After upgrading, the first capture records this regrouping once.
- CI: new job "Config Sync & Merge (two nodes)": two daemons with separate databases, paired, partitioned with iptables, changed on both sides, reconnected; verifies merge, conflict, resolution and detach. Releases are gated on it.
Added (Design 0001, Phase 3a)
- Cluster quorum with Corosync and a third vote (System › High Availability › Cluster quorum,
/api/quorum/*): form a cluster with a paired node (addresses suggested), see members, votes and quorum. Add a third vote without SSH: one pasted command on any Linux machine (witness-setup.shinstalls and enrollscorosync-qnetdwith a one-time code; apt, dnf, yum, zypper; opens TCP 5403 in ufw/firewalld), or Serve as third vote on another DPlaneOS system. With a cluster configured, its quorum replaces the heartbeat majority in the HA engine (watchdog reset only while quorate); automatic failover is refused with only two votes or outside the quorate partition, and the GUI says why. NixOS modulemodules/cluster.nix(units started only once configured; state in/var/lib/dplaneos/corosync; UDP 5405, TCP 5403). - CI: "Cluster Quorum (3 VMs)": two DPlaneOS nodes and a plain Linux witness enrolled with the one-line command, including a network partition. Releases are gated on it.
Removed (Design 0001, Phase 3e)
- Patroni, etcd, HAProxy, keepalived and the legacy HA engine. High availability is now built only from node-local databases with configuration exchange, Corosync quorum with a third vote, storage groups, watchdog self-fencing and optional power fencing. Removed:
services.dplaneos.ha(nixos/ha.nix), the etcd witness (patroni-witness.nix, the witness installer ISO and the installer's "Install Witness Node" entry), the daemon's heartbeat/promotion engine (peers, promotion, switchover, STONITH sequencing, SBD leases, HTTP and network witnesses, cluster secret, legacy replication, maintenance mode, the HA on/off toggle) with their API endpoints and the--ha-local-id,--ha-local-addrand--ha-cluster-secretflags, and the related CI jobs and VM tests. The Patroni setup could not have served the daemon (it never created thedplaneosrole or database and refused the replica node's connection), so no working installation depends on it. Kept: watchdog and power-fencing settings (now in their own migration), the SCSI-3 probe, hardware detection, CTDB (which now takes its node list fromservices.dplaneos.ctdb.nodes). Tables of the legacy engine stay unused in existing databases. Prometheus HA metrics now describe the cluster, the watchdog and storage groups; the HA alert rules referred to metrics that were never exported and are rewritten.
Added (Design 0001, Phase 3e)
- DPlaneOS Witness images. A ready-to-flash third-vote appliance: a Raspberry Pi 3/4 SD card image and x86_64 images (raw for USB/SSD, qcow2 for VMs), attached to releases. It runs as QDevice or voter; it joins headlessly from
dplaneos-witness.txton the boot partition (node address, one-time code, role), from a console wizard, or over SSH (dplaneos-witness join). New VM test of the headless join and a reboot of the witness. - The third vote from anything. Besides a QDevice (one command on any Linux machine, or another DPlaneOS system), a voter: a Raspberry Pi, mini PC or VM that runs only corosync and joins as a full member with the same one-time code (
witness-setup.sh --voter); it pins the node's TLS key and pulls its configuration every minute, so added or removed members reach it. Clusters can grow beyond two DPlaneOS nodes (Add a vote or node,POST /api/quorum/nodes); members and voters can be removed. The HA page compares the four paths; THIRD-VOTE.md documents each with its strengths and limits. - Secrets between paired nodes. User passwords (with SCRAM verifiers and TOTP), the LDAP bind password, ACME DNS credentials and certificate keys now reach the other nodes. Revisions hold plaintext fingerprints; the material is fetched from the node that changed it, encrypted for the pair, checked against the fingerprint and sealed under the receiving node's own key. LDAP, ACME and certificates join the exchanged kinds.
- New HA documentation: HIGH-AVAILABILITY.md and HA-FAILURE-MODES.md rewritten for the new model (topologies with their trade-offs, setup, failover timeline, operations, every failure scenario with what to do).
- Floating address per storage group. Held by the owner while it serves the group, announced with gratuitous ARP, removed before a planned move releases the pools; replaces the keepalived VIP.
- Shared pools are imported by the owner, not at boot. Pools of shared groups are kept out of the boot-time import (
cachefile=none); the owner imports them once the other nodes confirm they do not use them, or after a silent node left the cluster and the fencing delay passed. Import here on the owner imports on request. Manual imports (pool page, disk hot-plug) of a shared group's pool are refused on nodes that do not own it.
Fixed (Design 0001, Phase 3e)
- Watchdog without a hardware driver. With the HA module gone nothing loaded
softdog; the daemon now loads it when the watchdog is enabled and no/dev/watchdogexists (it was silently off otherwise). - Apps restarted on the old owner during a planned move. The activation loop could start a group's stack again between stopping it and exporting the pools; groups being moved or released are now left alone, and a failed pool check (for example a
zpool listtimeout) no longer drops the floating address. - Go 1.26.9 and golang.org/x/net v0.60.0 (GO-2026-6611 to GO-2026-6617, net/http).
Added (Design 0001, Phase 3d)
- Apps and NVMe-oF exports follow their storage group. Docker stacks with volumes on a group's pools and NVMe-oF exports of its zvols run only on the group's owner. The owner publishes their definitions to the other nodes of the group (
group_resources); a planned move stops them before the pools are released (and is refused if they cannot be stopped), and the new owner starts them after a move, failover or takeover. The NVMe-oF target on each node leaves out the exports of groups it does not own and is reconfigured only when its list changes.
Fixed (Design 0001, Phase 3d)
- Docker commands bypassed the command whitelist. All daemon calls to
dockerran without argument checks (and logged a security warning). They now go throughdocker_compose(projects under the allowed directories, a fixed set of subcommands and flags) anddocker_cli(read-onlyps,stats,inspect); container names taken from requests are validated.
Added (Design 0001, Phase 3c: replicated groups)
- Replicated storage groups: the owner snapshots its pools recursively on the group's interval and streams
zfs send -R(incremental from the newest common snapshot) to the other candidates over the paired-node channel (no SSH setup); copies stay read-only; old replication snapshots are pruned except those a lagging node still needs. Receivers accept streams only from the owner at the current epoch and refuse to roll back changes made to their own copy (split); the GUI shows it with "Discard the changes here". Planned move (final replication, direction flips), manual takeover, and optional automatic failover per group. "Replicate now", last replication per node in the GUI. nginx passes replication streams without size limit or buffering; the receive endpoint lifts the daemon's 30 s body read deadline. - CI: VM test for replicated groups (replicate, move, replicate back, split refused until discarded).
Added (Design 0001, Phase 3c)
- Automatic failover per storage group with the ADR-0009 baseline (third vote + watchdog self-fencing; power fencing optional): when the owner leaves the quorate partition, the first other candidate waits the fencing delay (watchdog timeout + 15 s), fences by IPMI/PDU if configured, and takes over (epoch + 1, import past multihost). Shared-storage groups; replicated groups follow.
- Watchdog follows Corosync quorum (Proxmox model): a node that owns a storage group and loses quorum stops resetting its watchdog; nodes without storage, and nodes in a deliberate corosync restart, keep resetting it. The node-level failover engine steps aside when storage groups manage failover.
- Manual "Take over" for groups whose owner is unreachable when automatic failover is off (two votes, no fencing); confirmed by the operator, still protected by multihost.
- Protection summary (
/api/ha/protection, HA page): third vote, watchdog (hardware or softdog, with its limitation), multihost per shared pool, SCSI-3 reservations, power fencing, each with what it adds; automatic failover on/off with the reason and the expected delay. - The cluster reconfiguration window ends as soon as the node is quorate with the configured votes (adding a third vote no longer holds automatic failover back for the full 60 s).
- CI: VM test with three VMs (shared disk, softdog, third vote): owner cut off stops resetting its watchdog, b takes over after the fencing delay, the old owner follows the new epoch after reboot; the two-node test covers manual takeover.
Added (Design 0001, Phase 3b)
- Storage groups and epochs (System › High Availability › Storage groups,
/api/groups): pools grouped with their candidate nodes and topology (standalone, shared storage, replicated); one owner at a time; the epoch increases with every change of owner. Group definitions are pushed to all members and pulled every 10 s, so a node that missed a change (partition, restart) adopts it on reconnect and releases shared pools it no longer owns. Planned move of shared-storage groups (export, epoch + 1, import on the target; pools taken back if the target cannot import). Group-scope configuration revisions carry the epoch; revisions from a former owner are refused by the other nodes (shown as blocked). - CI: the VM job also runs a shared-disk test: two nodes attached to one image, multihost pool, group creation, move there and back, multihost refusing a second import, a stale node correcting itself by pulling.
Fixed (found during Phase 3a)
- HA promotion reported success while steps failed.
ExecutePromotiononly logged errors: a failed pool import was followed by service restarts and "Promotion sequence complete", and the manual promote job always ended as succeeded. Each step is now reported (pools, datasets made writable, CTDB, SMB, NetBIOS, NFS, Docker, Patroni); a failed import stops the promotion (failed), other failures mark it degraded. The manual promote job fails with the list of problems; the last automatic promotion is shown on the HA page, and the post-promotion stack apply is skipped when the pools were not imported. The Patroni call now checks the HTTP answer. - SMB and NetBIOS were never restarted on failover: the calls used
restart smbd/restart nmbd, which the command whitelist refused (it allowed onlyreload-or-restart), and NixOS names the unitssamba-smbd/samba-nmbd. CTDB was never started on failover: its command was not in the whitelist. Both fixed; CTDB is skipped (and reported as skipped) when it is not enabled. modules/samba.nix: the shares-config stub was ordered beforesmbd.service, which does not exist on NixOS, so systemd ignored the ordering; nowsamba-smbd.service.
Fixed (found during Phase 1)
- GitOps apply deleted resources that
state.yamldid not declare, whateverignore_extraneoussaid. The database sync before every apply deleted all groups and group memberships, all NFS exports and undeclared shares, and deleted and re-created users declared in Git (losing their row and password hash). Withignore_extraneous: trueit now only adds and updates declared resources; undeclared ones are removed only withignore_extraneous: false, as documented. Declared users are updated in place.
Documentation
- GITOPS-DRIVEN-NAS.md: "Auto-Apply on Push" (polling, webhook) was documented but never implemented; marked as planned (Design 0001, Phase 5). Web UI write-back behaviour, HA writer rules and secrets rules documented; example hashes made valid.
- HIGH-AVAILABILITY.md: GitOps writer rules and a shared-secrets-key procedure for new and existing pairs.
🚀 Installation
curl -fsSL https://get.dplaneos.io | sudo bash -s -- --version 15.0.0