# sysinfo client specification (v3.8.0)

Status: contract for the v3.8.0 rewrite on trunk. As of 2026-10-21 the Windows client (§7) is built and published on `testing/`, with two beta testers (one Windows 10 Pro, one Windows Server 2019). Written 2026-10-11 (CAMP Stage 0b). Where this spec and the code disagree, the spec wins; where the spec is silent, the behaviour of tag `v3.7.1` (the deployed version) wins. "MUST" items are checked by the review stages R1–R3 using the checklists at the end of each section.

## 0. Terms and references

- **v3.7.1**: svn tag `camp_sysinfo_client_3/tags/v3.7.1`, deployed on most of the ~50 hosts (two run v3.7.2, a trunk snapshot). It is the compatibility reference. Main program `sysinfo-client`, sensors in `modules/`, transports in `scripts/`.
- **Trunk WIP**: the unfinished v4.0.1 rewrite, preserved as tag `v4.0.1-wip`. Its sensor template, `library.pm`, config loader and logging are kept; its regressions are not.
- **Old CAMP**: the current server. It ingests YAML, ignores unknown **top-level** sections, does not tolerate unknown keys or type changes inside old sections.
- **Sensor**: an executable in a module directory that prints tab-separated lines (§2). **Transport**: a Perl script in a script directory exposing `doit()` (§3.5).
- **Fixtures**: real command output captured by the owner under `t/fixtures/<host>/<command>.txt`, plus `t/fixtures/<host>/v3.7.1-report.yaml` (v3.7.1 run with `-t -p`).
- Versions: client `v3.8.1` (stays 3.x so version checks in old CAMP keep passing; 3.8.1 is the repository split, no report change); data version `v3.8.0`.
- Platforms: Debian/Devuan Linux, FreeBSD, OPNsense (all first-class). RPM distros and IPFire are dropped. The Windows client (bug 211) is specified in §7; the Unix sections of this document do not apply to it unless §7 says so.
- Repository layout (v3.8.0): the Unix client is `unix/` (`sysinfo_client` main program, `sysinfoconf.pm`, `library.pm` shared, `sensors/` (was `modules/`), `transports/` (was `scripts/`), `installer/`, `configure`, `dataserialization/` (dsEdit, an svn external pinned to a revision), `sysinfo_update`, `SYSTEM_VERSION`) and a Unix host installs only that directory. The Windows client is `windows/` (§7). At the root: `server/` (server-side `upload_file.php`, `test_upload.html`, formerly in `scripts/`; not transports), `tools/`, `t/` (Unix tests and `t/fixtures/`; Windows tests in `t/windows/`), `Documentation/`, `reference/`. **In the Unix sections of this document a path such as `sensors/` or `installer/setup` is relative to `unix/` (the install directory) unless it begins with `t/`, `tools/`, `server/` or `windows/`.** The config keys keep their v3.7.1 names `moduleDirs` (sensor directories) and `scriptDirs` (transport directories) so existing configs stay valid.
- Perl 5.24+, core modules plus `YAML::Tiny`. JSON via `JSON::PP` (core). No other CPAN dependency. No string `eval` of data; no shell interpolation of values from config, `server.info` or sensor output (list-form `system`/`open '-|'`).

### 0.1 Perl conventions (this project's answers to `CommonProjectStandards/Perl-Standards.md` §2)

All Perl follows `CommonProjectStandards/CodingStandards.md` and `Perl-Standards.md`. Points that matter most here:

- File header on every file (`CodingStandards.md` §3): filename, purpose, FreeBSD 2-clause licence pointer, revision history (date, author, version, svn revision when known; newest at the bottom, as the existing files). Versioning is **per file** (each file's `$VERSION`), as the project already does; the client's `$VERSION` is the release version.
- `##` comment header above every sub: purpose, arguments, return value, side effects (§4); major blocks commented (§5). No POD.
- camelCase for subs and variables in new code; existing snake_case names need not be renamed. Executables have no `.pl` suffix and use `#!/usr/bin/env perl`.
- Procedural, no object system. Shared code in `.pm` files at the root of `unix/` (`library.pm`, `sysinfoconf.pm`), found via `FindBin` (`use lib "$FindBin::Bin"` and `"$FindBin::Bin/.."`), no namespace prefix; never copy-pasted between sensors.
- Errors: sensors use exit codes (§2.3); the client dies only before the run can start (not root, no config, no `clientName`) and otherwise records failures and carries on; recoverable helpers return `($ok, $message)`.
- Dependencies: core Perl plus `YAML::Tiny`; no `cpanfile`.
- Tests: `Test::More` under `t/`, run with `prove -l t/` (this project's choice; it differs from the `zfs_utils` default because sensors are tested against captured fixtures).
- No `perltidy`/`Perl::Critic` profile; 3-space indentation as in the existing code.
- Functions past ~50 lines or mixing phases are split (Extract Method, §9). Complex hash/array literals formatted per §11.
- Each directory with executables (`sensors/`, `transports/`, `installer/`, `tools/`) has a `README.md` per §12 (purpose, inventory, usage with two examples; cron example where unattended).
- No real hostnames in documentation, comments or test names (§14); fixtures are named by role (§5).

## 1. Report contract

### 1.1 Format

- YAML via `YAML::Tiny`. First line exactly `#sysinfo: v3.8.0 YAML` (no space after `#`), then the document starting `---`.
- `outputType: json` in config stays supported (JSON body, no header line). Default YAML.
- Test mode (`-t`/`--test`, or config `TESTING` true): write `/tmp/sysinfo.testing.yaml`, call no transport.

### 1.2 Old sections (compatibility)

Every key path v3.7.1 produces on a host MUST be produced by v3.8.0 on the same host with the same name, nesting and type (scalar / map / list), and the same value except where listed under "Allowed differences". Nothing new is added inside old sections, except `xen.virtual.<guest>.memory|numcpu` (v3.7.1 code intended them).

| Section | Keys | Producer (§2.7) |
|---|---|---|
| `report` | `client`, `date` (`YYYY-MM-DD HH:MM:SS`, local), `version` (`v3.8.0`) | client |
| `system` | `hostname`, `serial` (= effective `serialNumber`, `''` if none), `UUID` (= effective `UUID`); every config key except `moduleDirs`, `scriptDirs`, `transports`, `logging`, `sensorTimeout`, `update` (fleet: `clientName`, `serialNumber`, `location`, `os_type`, `tags` list, sometimes `TESTING`, `postRunScript`); nothing added by the CLI or loader (`testing`, `outputType`, `dataType`) | client |
| `system` | `last_boot` (epoch), `uptime` (seconds) | `linux_system`, `freebsd_system` |
| `system` | `Power Draw`, `Power Current N`, `Power Voltage N` (strings with unit) | `all_ipmi` |
| `attributes` | flat `'<dev> <Key>'`, Key ∈ `Capacity` (bytes, no commas), `Make` (smartctl Model Family), `Model`, `Serial`, `Rotation` (text), `Sector Size`, `type` (raw `-d` string from `smartctl --scan`, trailing space kept), `uptime` (leading digits of attr 9 raw), `writes`. Monthly (§2.5). `VBOX HARDDISK` excluded as v3.7.1 | `all_smart` |
| `diskinfo.<name>` | filesystems from `df` (`fstype`, `size`, `used` KiB, `mount`) | `all_filesystems` |
| `diskinfo.<name>` | md (`level`, `components`, `size`), LVM (`fstype` = `lvm2 lv|vg|pv`, `size`, `used`, `vg`, `components`), ZFS (`fstype` = `zfs`, `size`, `used`, `level`, `components`: the v3.7.1 flattened string, kept), FreeBSD raw disks (`size` KiB) | `linux_mdadm`, `linux_lvm`, `all_zfs`, `freebsd_storage` |
| `network.<if>` | `address`, `netmask`, `ip6address`, `ip6networkbits`, `mac`, `mtu` (netmask formats differ per OS, kept) | `linux_network`, `freebsd_network` |
| `network.ipmi` | as v3.7.1 `all_ipmi_network` | `all_ipmi` |
| `operatingsystem` | Linux `distribution`, `description`, `release`, `codename`, `os_name`, `kernel`, `os_version`; FreeBSD/OPNsense `os_name`, `kernel` (v3.7.1 got these from `linux_os_values`, which had no OS gate and ran `uname -s`/`-r` on FreeBSD too) | `linux_system`, `freebsd_system` |
| `pci.<slot>.<field>` | Linux as v3.7.1 `linux_pci_lspci`; FreeBSD see F3 | `linux_pci`, `freebsd_pci` |
| `software.<pkg>` | `version`, `description` (dpkg: starts with the architecture, kept; pkg) | `linux_dpkg_software`, `freebsd_pkg_software` |
| `software.sysinfo-client.version` | exactly one entry, dash name, the client version (`v3.8.1`) | client |
| `xen.virtual.<guest>.<field>` | Xen (`xl`) and libvirt guests, category `xen` | `linux_virtual` |

Allowed differences (value only; name and type never change):

- **F1** `attributes.'<dev> writes'` = `storage.disks.<dev>.smart.bytes_written` (§1.4); absent when that is absent. Every v3.7.1 value is a memory address × sector size.
- **F2** `attributes.'<dev> Sector Size'` also filled from smartctl `Sector Sizes:` (the logical size), so it may be newly present.
- **F3** FreeBSD `pci` becomes `pci.<selector>.<field>` (v3.7.1 produced the scalar `pci: rev` on FreeBSD 13+). Fields: `driver` (text before `@`); from the header line `class`, `rev` → `revision`, `hdr` → `header`, and whichever of `card`, `chip`, `vendor`, `device`, `subvendor`, `subdevice` are present; then the indented `key = value` lines (`vendor`, `device`, `class`, `subclass`) override, values processed as v3.7.1 (`cleanUp`, quotes kept). Never a line with an empty selector.
- `system.*` values may change where `server.info` overrides the config (§3.3).
- Time-varying: `report.date`, `report.version`, `software.sysinfo-client.version`, `system.uptime`, `system.last_boot`, `system.Power *`, `diskinfo.*.used`, `diskinfo.*.size` of mounted filesystems, `attributes.* uptime`, `xen.virtual.*.memory|numcpu` (may be newly present).

### 1.3 `identity` (new section, sensors `linux_identity` and `freebsd_identity`, daily)

| Key | Linux | FreeBSD / OPNsense |
|---|---|---|
| `system_uuid_raw` | `dmidecode -s system-uuid`, else `/sys/class/dmi/id/product_uuid` | `kenv -q smbios.system.uuid`, else `sysctl -n kern.hostuuid` |
| `system_uuid` | normalised `system_uuid_raw` (rule U) | same |
| `system_uuid_status` | `ok`, `placeholder`, `missing` (rule P) | same |
| `system_serial`, `system_serial_status` | `dmidecode -s system-serial-number`, else `/sys/class/dmi/id/product_serial` | `kenv -q smbios.system.serial` |
| `board_serial` | `dmidecode -s baseboard-serial-number` | `kenv -q smbios.planar.serial` |
| `system_manufacturer`, `system_product` | `dmidecode -s system-manufacturer`, `-s system-product-name` | `kenv -q smbios.system.maker`, `smbios.system.product` |
| `machine_id` | `/etc/machine-id`, else `/var/lib/dbus/machine-id` | `/etc/hostid` |
| `os_distribution`, `os_version`, `os_nickname` | not emitted | OPNsense only: `opnsense-version -O` (JSON) `product_name`, `product_version`, `product_nickname`; absent when the command is missing. Not in `operatingsystem`, where `os_name` stays `FreeBSD` (§1.2) |
| `virtualization` | `virt-what` if present, else inferred from manufacturer/product (QEMU, KVM, Xen, VMware, VirtualBox/innotek, Microsoft Virtual Machine) and `/proc/xen`; `none` if physical | `sysctl -n kern.vm_guest` |
| `configured_uuid`, `configured_serial` | config file `UUID`/`serialNumber` (env, below) | same |
| `server_info_uuid`, `server_info_serial` | from `server.info` when present (env) | same |
| `uuid`, `uuid_source` | effective UUID (32 hex) and `server_info`, `dmi` or `config` | same |
| `serial`, `serial_source` | effective serial and `server_info`, `dmi`, `config` or `uuid` | same |

- **Rule U (normalise)**: lower-case, remove every non-hex character; valid only if exactly 32 hex digits remain. CAMP stores this form (`char(32)`).
- **Rule P (placeholder)**: UUID is `missing` if empty or `Not Settable`/`Not Present`; `placeholder` if not valid under U, or normalised all `0`, all `f`, `03000200040005000006000700080009`, `00020003000400050006000700080009`. Supermicro `00000000-0000-0000-0000-<MAC>` is `ok` (unique). Serial is `missing` if empty; `placeholder` if (case-insensitive, trimmed) it matches `^(1234567890|0123456789|0+|NotSpecified|Not Specified|Not Settable|None|N/A|Default string|To Be Filled By O\.E\.M\.|System Serial Number|Chassis Serial Number|0123456789ABCDEF|Unknown|\.+)$`. The lists live in `library.pm` (`isPlaceholderUUID`, `isPlaceholderSerial`, `normaliseUUID`) and replace `%serialNumberTranslation`/`%UUIDTranslation` in `sysinfoconf.pm`.
- **Effective UUID**: first valid, non-placeholder of: `server.info` UUID, live DMI UUID, config UUID. Absent if none.
- **Effective serial**: first non-placeholder of: `server.info` serial, live DMI serial, config serial; else, if `virtualization` ≠ `none` and `uuid` exists, `uuid` (source `uuid`); else absent. Never an invented value (the v3.7.1/trunk md5 fallback is removed).
- The client passes `SYSINFO_CONFIGURED_UUID`, `SYSINFO_CONFIGURED_SERIAL` (config file values before `server.info`), `SYSINFO_SERVERINFO_UUID`, `SYSINFO_SERVERINFO_SERIAL` (unset when absent) in every sensor's environment.
- Old `system.UUID`/`system.serial` are not normalised or replaced by these.
- CAMP matching (for the server side): `identity.uuid`, then `identity.serial`, then client + hostname, warning on any non-UUID match. For reports without `identity` CAMP applies rules U and P to `system.UUID`/`system.serial` itself.

### 1.4 `storage` (new section)

All keys tab paths under `storage`; sizes integer bytes; booleans `0`/`1`; absent = unknown; units in key names. `<dev>` = kernel name without `/dev/` (`sda`, `da4`, `nvme0n1`); a physical disk behind a hardware RAID controller (`smartctl --scan` `-d megaraid,N`, `sat+megaraid,N`, `cciss,N`, `3ware,N`, `areca,N`) is `<base>_<type>_<N>` (`sda_megaraid_4`) with `behind_controller` = type. Disk discovery and key derivation is one shared function in `library.pm` so both storage sensors agree.

**Disk discovery** (Linux `lsblk -J -b -O`, fallback `lsblk -J -b -o NAME,KNAME,TYPE,SIZE,MODEL,SERIAL,WWN,VENDOR,REV,TRAN,ROTA,LOG-SEC,PHY-SEC,PKNAME`, type `disk`; FreeBSD `sysctl -n kern.disks` + `geom disk list`). Exclude loop, zram, nbd, rbd, optical (`sr*`, `cd*`), SES (`ses*`), `dm-*`, `md*`, iSCSI/CTL LUNs (`tran` = `iscsi`, vendor/model `CTLDISK`).

**Daily, sensors `linux_storage` and `freebsd_storage`** — `storage.disks.<dev>`:

| Key | Source |
|---|---|
| `device` | `/dev/<dev>` |
| `serial`, `wwn`, `vendor`, `model` | lsblk `serial`/`wwn`/`vendor`/`model`; geom `ident`/`lunid`/`descr` |
| `transport` | `sata`, `sas`, `nvme`, `usb`, `virtio`, `xen`, `scsi`, `unknown` (lsblk `tran`; FreeBSD driver name) |
| `size_bytes`, `logical_sector_bytes`, `physical_sector_bytes` | lsblk `size`/`log-sec`/`phy-sec`; geom `Mediasize`/`Sectorsize`/`Stripesize` |
| `rotation_rpm` | geom `rotationrate`; lsblk `rota` = 0 → `0`; else absent |
| `is_virtual`, `logical_volume`, `behind_controller` | virtio/xen/QEMU/VBOX/VMware/Hyper-V; vendor DELL/LSI/AVAGO/BROADCOM/HP with PERC, MR, MegaRAID or LOGICAL VOLUME in model; as above |
| `slot` | FreeBSD `sesutil map`; Linux `/sys/class/enclosure/*/*/device/block/<dev>`. Never guessed |
| `health\tsource` | `json` or `none` (no smartctl ≥ 7.0, or no SMART) |
| `health\tpassed` | `smartctl -j -H -A [-d type]`: `smart_status.passed` → 1/0 |
| `health\treallocated_sectors`, `pending_sectors`, `offline_uncorrectable` | ATA attrs 5, 197, 198 raw |
| `health\tmedia_errors`, `critical_warning` | NVMe health log |
| `health\tgrown_defects` | SCSI `scsi_grown_defect_list` |

Also daily in `linux_mdadm`, `linux_lvm` and `all_zfs` (all members resolved to a `<dev>` as `disk`, plus `partition` when the member is a partition; unresolvable → `disk_unresolved` = 1; resolution via lsblk `pkname`, `readlink -f`, FreeBSD `gpart show -p`/`geom part list`, `glabel status`):

- `partitions.<part>.disk|size_bytes`.
- `md.<md>.level|state|degraded|size_bytes|raid_devices`; `md.<md>.members.<m>.disk|partition|role|slot`, `role` ∈ `active`, `spare`, `failed`, `missing` (from `/proc/mdstat`, `mdadm --detail --export`, `/sys/block/<md>/md/array_state|degraded`).
- `zfs.<pool>.health|size_bytes|allocated_bytes|free_bytes` (`zpool list -pH -o name,size,alloc,free,health`); `zfs.<pool>.vdevs.<vdev>.class|type|state`, `class` ∈ `data`, `log`, `cache`, `spare`, `special`, `dedup` (section headers in `zpool status -P <pool>`, parsed by indentation), `type` ∈ `mirror`, `raidz1|2|3`, `draid…`, `disk`; `…members.<m>.disk|partition|state|read_errors|write_errors|checksum_errors`.
- `lvm.pvs.<pv>.vg|size_bytes|free_bytes|disk|partition|md`; `lvm.vgs.<vg>.size_bytes|free_bytes`; `lvm.lvs.<vg>/<lv>.vg|size_bytes|attr` (`pvs|vgs|lvs --units b --nosuffix --noheadings --separator '|'`).
- Old `diskinfo` md/LVM/ZFS/FreeBSD raw-disk keys, byte-identical to v3.7.1 logic (§1.2).

Expected structures (fixture roles, §5): `freebsd-zfs` pool `storage` has `raidz2-0` (class `data`, da4–da13) and `mirror-1` (class `dedup`, da2, da3); `zroot` `mirror-0` members `da0p2`/`da1p2` with `disk` `da0`/`da1`. `linux-md-lvm` `md1` raid6, eight members `sdc1…sdj1` → `sdc…sdj`, role `active`; `lvm.pvs./dev/md1.md` = `md1`, `vg` = `vg-md1`.

**Monthly, sensor `all_smart`** (gated, §2.5; `smartctl -j -x <dev> [-d type]`, smartmontools ≥ 7.0; without `-j` only the old `attributes` keys, by the v3.7.1 text logic, and `smart.source` = `none`) — `storage.disks.<dev>.smart`:

| Key | Source |
|---|---|
| `source` | `json` or `none` |
| `model_family`, `firmware`, `serial`, `wwn` | `model_family`, `firmware_version`, `serial_number`, `wwn` (`naa-oui-id` hex) |
| `power_on_hours` | `power_on_time.hours` (disk age) |
| `temperature_c` | `temperature.current` |
| `bytes_written`, `bytes_written_source` | rule W |
| `writes_raw`, `writes_raw_unit` | the counter rule W used and its unit word |
| `wear_percent_used`, `wear_source` | rule E |

- **Rule W** (first match): (1) device statistics `Logical Sectors Written` × `logical_block_size` → `devstat`; (2) attr 241 named `Total_LBAs_Written` (or 246 on Micron) × `logical_block_size` → `attr241_lba`; (3) attr named `*_GiB` × 2^30 → `attr_gib`; (4) attr named `*_32MiB` × 2^25 → `attr_32mib`; (5) NVMe `data_units_written` × 512000 → `nvme_data_units`; (6) SCSI `scsi_error_counter_log.write.gigabytes_processed` × 10^9 → `scsi_gb_processed`. Otherwise absent; never a guess.
- **Rule E** (first match, 0 = new, 100 = rated life used): NVMe `percentage_used` → `nvme`; device statistics `Percentage Used Endurance Indicator` → `devstat`; `scsi_percentage_used_endurance_indicator` → `scsi`; ATA attr named `Media_Wearout_Indicator`, `Wear_Leveling_Count`, `SSD_Life_Left` or `Percent_Lifetime_Remain`: 100 − normalised value → `attr_<name>`. Absent for HDDs.
- JSON field names are verified against fixtures; a mismatch is reported, not guessed around.
- Monthly keys are absent on other days by design; CAMP keeps the last value. `sysinfo.sensors.all_smart.status` = `not_due` says so.

### 1.5 `sysinfo` (new section, written by the client)

`client_version`, `data_version`, `config_file` (path used), `server_info_file` (path used, absent if none), `sensors.<filename>.status` (`ok`, `wrong_os`, `not_applicable`, `not_due`, `error`, `timeout`, `skipped_permission`; exit code to status mapping in `sensors/Documentation/README.md`), `.seconds` (wall time, 1 decimal), `.exit_code`, `.message` (first STDERR line or first rejected line, ≤ 200 chars; only when non-empty), `.rejected_lines` (only if > 0).

### 1.6 Report checklist (R1: client-side items; R2: sensor items)

- [ ] Header line exact; YAML::Tiny; JSON path unchanged in shape.
- [ ] `tools/compare_reports` vs every `t/fixtures/*/v3.7.1-report.yaml`: no missing old key, no type change; value differences only in the allowed list.
- [ ] Exactly one `software.sysinfo-client.version`; category `xen`, not `virtual`.
- [ ] `system` has no `testing`/`outputType`/`dataType`/`sensorTimeout`/`update`.
- [ ] No md5 or other synthesised serial anywhere.
- [ ] `identity` statuses correct for fixtures: placeholder UUID host, `1234567890` serial host, VM serial = UUID.
- [ ] `storage` structures for `freebsd-zfs` and `linux-md-lvm` as stated; every md/zpool/PV member resolves to a `storage.disks` key or carries `disk_unresolved`.
- [ ] `writes` and `bytes_written` equal; absent where rule W has no match; spot value matches `smartctl -a` fixture arithmetic.
- [ ] `sysinfo.sensors` lists every sensor run.

### 1.7 `havirt_node`, `havirt_domain` (new sections, sensor `linux_havirt`, daily)

`havirt` is the owner's tool for libvirt clusters (installed on shared NFS, linked as `/usr/local/bin/havirt` on each node). Every node sees the whole cluster, so each node reports **only itself and the guests running on it**; CAMP gets each guest from the hypervisor running it, as with `xen.virtual`. The sensor runs exactly two commands, `havirt node list --format tsv` and `havirt domain list --extended --format tsv`. Both read havirt's status file, which `havirt node scan` refreshes from cron; the sensor never runs `scan` or any other havirt command (scan uses SSH to every node and takes a lock).

- **This node**: havirt names nodes by the output of `hostname`. The sensor runs `hostname` and matches it against the `Node` column exactly; if that fails, it compares the first dot-separated label of each side, case-insensitively.
- **TSV parsing**: columns are read by header name, not position, so new havirt columns pass through. In the extended domain list, a line with no leading whitespace is a summary row, `  NICs:` and `  Disks:` start a detail block, and each 4-space-indented line is a tab-separated detail of the preceding summary row: NIC = `dev`, `bridge`, `mac`; disk = `target`, `type`, `source`. Unknown block names are skipped.
- `havirt_node.name` = this node's `Node` value; `havirt_node.<column>` = every other column of its `node list` row (`clock`, `cpu_cores`, `cpu_count`, `cpu_model`, `cpu_socket`, `maintenance`, `memory` KiB, `numa_cells`, `threads_per_core`, …).
- `havirt_domain.<domain>.<column>` = every column of the summary row except `Node` and `Domain` (`maintenance`, `memory` KiB, `nic_count`, `vcpu`, `vnc` = the assigned VNC port, …). Only rows whose `Node` is this node.
- `havirt_domain.<domain>.nic.<mac>` = bridge. The MAC is lower-cased. The `dev` name is not reported: host-side tap names (`vnetN`) change on every guest start.
- `havirt_domain.<domain>.disk.<target>.type` (`block`, `file`, …) and `.source` (path, or havirt's `unknown`), verbatim.
- Values verbatim. **An empty value is omitted, never printed**: the §2.4 split drops a trailing empty field, which would turn the key into the value (havirt prints `maintenance` empty when off).
- Exit codes per `sensors/Documentation/README.md`: 2 (not applicable) when `havirt` is not found in `PATH` or at `/usr/local/bin/havirt`, or this host has no row in `node list` (`not a havirt node` on STDERR); 4 (error) when either havirt command exits non-zero or prints no header (e.g. the NFS share is not mounted), first STDERR line = the reason. A node with no running guests emits only `havirt_node` and exits 0.
- Linux only. Runs beside `linux_virtual`, which is unchanged (`xen.virtual.<guest>` from `virsh list`).
- For CAMP (not the sensor): a guest's NIC MAC matches `network.<if>.mac` in that guest's own report, which links guest to device when names differ. Windows guests, which run no sysinfo, are known only from this section.

## 2. Sensor interface

### 2.1 Discovery

For each directory in `moduleDirs` (order kept; nonexistent directories logged and skipped; if none exist, `<bindir>/sensors`): plain files whose name matches `^[a-zA-Z0-9][a-zA-Z0-9_]*$`, executable, owned by root, no group or world permission bits (0700 or stricter), as v3.7.1 `validatePermission`. Failures → status `skipped_permission`. Permission checks are skipped in test mode. Sorted by filename within a directory. A name seen in an earlier directory is not run again from a later one.

### 2.2 Execution

- Child process, `<dir>/<file> <dir>/` (argv[0] = module directory with trailing slash, as v3.7.1).
- STDOUT and STDERR captured separately; STDIN `/dev/null`.
- Environment: inherited, plus `SYSINFO_*` (§1.3) and `SYSINFO_PERIODIC=1` when the run is periodic (§2.5).
- Timeout: config `sensorTimeout` seconds (default 120). On timeout kill the child's process group (TERM, then KILL after 5 s); status `timeout`.
- Nothing a sensor does (die, exit, hang, garbage) may stop the report.

### 2.3 Exit codes

The exit-code contract (codes, recorded statuses, STDERR use) is defined once, in `sensors/Documentation/README.md`. STDOUT is parsed only for exit 0. A signal exits as `error`; a timeout is `timeout`.

### 2.4 Line format and parser

- One fact per line: `category<TAB>key…<TAB>value`, 2–6 fields; split on `/ *\t */` (as v3.7.1). Last field = value, others = key path. Blank lines ignored.
- No `eval`. Walk/create nested hashes. Key `'0'` is valid. Rejected (not applied, counted, first one kept as message): fewer than 2 fields, an empty key segment, descending through an existing scalar, replacing an existing map with a scalar.
- Values stored as the exact raw string (no quote escaping). UTF-8 in, UTF-8 out.
- A later line with the same path replaces the earlier scalar (v3.7.1 behaviour).

### 2.5 Periodic gating

- `-p`/`--periodic` creates `/tmp/sysinfo.firstrun` at start; the client removes it at the end of every run (v3.7.1). `library::checkDate('w'|'m'|'y')` is true if that file exists or it is the first day of the week/month/year (v3.7.1 logic).
- A gated sensor that is not due exits 3 (`not_due`) and may print `not due` on STDERR.
- Only `all_smart` is gated (monthly).

### 2.6 Template and library

- `sensors/Documentation/template.basic` (trunk's `modules/documentation/template.basic`, moved) is the template: `package`, `$VERSION`, `$commandsList`, `$osList`, `$CATEGORY`, `library.pm` via `FindBin`. Its tail prints lines and exits per §2.3; no `do`-return idiom, no `error:` prefix.
- Parsing lives in functions that take text (or decoded JSON) and return lines, so `t/` can feed fixtures; the main body only runs commands.
- `library.pm` lives at `<bindir>/library.pm` and adds: `normaliseUUID`, `isPlaceholderUUID`, `isPlaceholderSerial`, `runCommand` (list form, returns stdout), `discoverDisks` (§1.4), `smartScan` (parse `smartctl --scan` / `-j --scan`, derive `<dev>` keys). Since bug 210 it also holds the code shared by several sensors: `localDiskInventory` (lsblk, or sysctl and geom, with the member resolver), the storage helpers (`collectHealth`, `emitStorageDisks`, `partitionLines`, `resolveMember`, ...), `fileReadable`, and the identity rules and `identityReport`.

### 2.7 Sensor list (v3.8.0)

Naming (bug 210): one job per sensor, for one OS. `<os>_<job>` runs on that OS only; `all_<job>` is used when the function is identical on every supported OS. A job may cover a whole report section. Wrong OS exits 1; right OS but the subsystem absent exits 2.

| Sensor | Replaces (v3.8.0) | Replaces (v3.7.1) | Emits | OS | When |
|---|---|---|---|---|---|
| `linux_system` | `unix_system` | `linux_system`, `linux_os_values` | `system.last_boot`, `system.uptime`, `operatingsystem.*` | Linux | daily |
| `freebsd_system` | `unix_system` | `bsd_system`, `linux_os_values` | `system.last_boot`, `system.uptime`, `operatingsystem.*` | FreeBSD | daily |
| `linux_network` | `unix_network` | `linux_networking` | `network.<if>` | Linux | daily |
| `freebsd_network` | `unix_network` | `bsd_networking` | `network.<if>` | FreeBSD | daily |
| `all_ipmi` | `unix_ipmi` | `all_ipmi_network`, `all_ipmi_power` | `network.ipmi`, `system.Power *`; exit 2 without `/dev/ipmi*` | all | daily |
| `linux_pci` | `unix_pci` | `linux_pci_lspci` | `pci` (`lspci`) | Linux | daily |
| `freebsd_pci` | `unix_pci` | `bsd_pciconf` | `pci` (base `pciconf -lv`, F3) | FreeBSD | daily |
| `linux_dpkg_software` | `unix_software` | `linux_dpkg_software` | `software.<pkg>` | Linux | daily |
| `freebsd_pkg_software` | `unix_software` | `bsd_pkg_software` | `software.<pkg>` | FreeBSD | daily |
| `linux_virtual` | `unix_virtual` | `all_xen`, `all_libvirt` | `xen.virtual.<guest>`; exit 2 without `xl` and `virsh` | Linux | daily |
| `all_filesystems` | `unix_filesystems` | `unix_partitions` | `diskinfo` from `df` | all | daily |
| `linux_storage` | `unix_storage` | (new) | `storage.disks`, `storage.partitions` | Linux | daily |
| `freebsd_storage` | `unix_storage` | `bsd_disk` | `storage.disks`, `storage.partitions`; old `diskinfo` raw disks | FreeBSD | daily |
| `linux_mdadm` | `unix_storage` | `all_mdadm` | old `diskinfo` md; `storage.md`; exit 2 without an array in `/proc/mdstat` | Linux | daily |
| `linux_lvm` | `unix_storage` | `all_lvm2` | old `diskinfo` LVM; `storage.lvm`; exit 2 without `lvs` | Linux | daily |
| `all_zfs` | `unix_storage` | `all_zfs` | old `diskinfo` ZFS; `storage.zfs`; exit 2 without `zpool` or a pool | all | daily |
| `all_smart` | `unix_smart` | `all_smart` | `attributes`; `storage.disks.*.smart` | all | monthly |
| `linux_identity` | `unix_identity` | (new) | `identity` | Linux | daily |
| `freebsd_identity` | `unix_identity` | (new) | `identity` (OPNsense version included) | FreeBSD | daily |
| `linux_havirt` | `unix_havirt` | (new) | `havirt_node`, `havirt_domain` (§1.7); exit 2 without `havirt` | Linux | daily |

Removed: `all_sysinfo` (client injects the self entry), `linux_rpm_software`, `ipfire_software`, `redmine`, trunk's split files (`linux_mdadm`, `bsd_pci`, …). Windows files untouched.

### 2.8 Sensor checklist (R1: framework; R2: sensors)

- [ ] Subprocess execution; exit-code mapping; timeout kills the process group; STDIN closed.
- [ ] Stub sensors (exit 4, `die`, sleep past timeout, empty-key line, garbage) each recorded; report still produced.
- [ ] v3.7.1 `modules/` run under the new client reproduce the v3.7.1 report (compare_reports clean except allowed list).
- [ ] Parser: no `eval`; `'0'` key; rejections; quotes and backslashes preserved.
- [ ] Every sensor: template form, exit codes per `sensors/Documentation/README.md`, parsing in testable functions, fixture tests in `t/`.
- [ ] Sensor list and outputs per §2.7; no sensor prints outside STDOUT lines; `all_smart` gated monthly.

## 3. Configuration, `server.info`, transports

### 3.1 Config file

Search order: `--config` path; `sysinfo_client.yaml` in `/etc/camp/sysinfo_client`, `/usr/local/etc/camp/sysinfo_client`; legacy `sysinfo-client.yaml` in `/etc/camp/sysinfo-client`, `/etc/camp`, `/usr/local/etc/camp/sysinfo-client`, then `.`. First found is used and logged (`sysinfo.config_file`). The search list is overridable for tests. YAML, JSON or DataTransport as trunk's loader detects. Re-read every run (the program is not resident).

### 3.2 Keys

| Key | Meaning | In `system`? |
|---|---|---|
| `clientName` | required; run aborts if empty after `server.info` | yes |
| `hostname` | default `hostname -f` (v3.7.1 `getHostName`) | yes (as `hostname`) |
| `serialNumber`, `UUID` | identity as configured at install; may be blank | yes |
| `location`, `os_type`, `tags` (list) | descriptive | yes |
| `TESTING` | test mode | yes (v3.7.1) |
| `postRunScript` | `{script name}` run with the config path after the run (v3.7.1) | yes (v3.7.1) |
| `outputType` | `yaml` (default) or `json` | yes if in the file (v3.7.1) |
| `logging` | `{log type: cache|file|syslog, log level: 0-5, log path}` | no |
| `moduleDirs`, `scriptDirs` | lists of directories | no |
| `transports` | map priority → `{name, sendScript, …}` | no |
| `sensorTimeout` | seconds, default 120 (new) | no |
| `update` | `{method: auto|tarball|svn, url: <tarball URL>, seed_url: <seed URL>, sha256_url: <checksum URL, optional>}` (new, §4.3–4.5). There are no built-in URLs: the site's release locations come from the seed (or the config) and nowhere else; the template carries them only as commented examples. Without `sha256_url` no checksum is checked | no |

Any other key in the file is copied into `system` (v3.7.1 behaviour).

### 3.3 `server.info`

- Path: first existing of `/etc/server.info`, `/usr/local/etc/server.info`. Re-read **every run**; overrides config values for this run only (the config file is not rewritten by the client).
- Format: `Key: Value` per line; split on the **first** colon only; key and value trimmed; keys case-insensitive; `#` lines and blank lines ignored; unknown keys ignored.
- Mapping: `owner` → `clientName`; `hostname` → `hostname` (only that key, as v3.7.1); `serial` → `serialNumber`; `uuid` → `UUID`; `location` → `location`; `os_type` → `os_type`; `tags` → `tags` (comma split, trimmed, empties dropped). An empty value does not override.
- Precedence: CLI options > `server.info` > config file > defaults. The overridden values feed `system.*`, transport globals and `SYSINFO_SERVERINFO_*`; `SYSINFO_CONFIGURED_*` carry the file values.
- `installer/mergeconfig` and the installer read it with the same reader (one function in `sysinfoconf.pm`). `configure` does not parse it: it is a wrapper that runs `dataserialization/dsEdit` on the config file (§3.4).

### 3.4 Command line

As v3.7.1/trunk: `--version`, `--help`, `-v` (repeatable), `-f/--config`, `--configtype`, `-o/--outputType`, `-p/--periodic`, `-t/--test`. Must run as root, except `-t` (which also skips permission checks).

`configure` (v2.0.0) edits the config file by running `dataserialization/dsEdit` on it (YAML/JSON/INI editor from `svn:externals`, pinned to a revision; bug 212). All its parameters go to dsEdit unchanged (`-a/--addentry 'key.sub:value'`, `'list[]:value'` to append, `-d/--delentry`, `-o/--outfile`); if none is a file name, the config file from the normal search is added. With no parameters dsEdit opens interactively. dsEdit v1.3 chooses the format from the file extension (`.yaml`, `.yml`, `.json`, `.ini`) unless `-f/--format` says otherwise, and `server.info` can be edited with `configure -f ini --delimiter ':' /etc/server.info` (comments in it are lost and keys are rewritten sorted). The merge of `server.info` and defaults into the config, run by the installer, is `installer/mergeconfig`.

### 3.5 Transports

- `scriptDirs` nonexistent entries are logged and skipped; if none exist, `<bindir>/transports`.
- Iterate `sort { $a <=> $b } keys %$transports`; for each with a `sendScript` found in `scriptDirs`, load it in a way that tolerates every script defining `main::doit`, call `doit($params, $report)`; stop at the first that returns `1`; log real return values of failures; if none succeeds, log and print the report to STDOUT (cron mails it).
- Globals merged into each transport hash (v3.7.1 names/formats): `upload_type` = `sysinfo`, `data version`, `report date` (same string as `report.date`), `client name`, `host name`, `serial number`, `UUID`; `fileType` may be added.
- Placeholder substitution without `eval`: literal tokens `$configuration{'hostname'}`, `$configuration{'clientName'}`, `$configuration{'serialNumber'}`, the same with `$config{…}`, `$reportDate`, `$hostname`.
- `transports/save_local`, `upload_http`: interfaces, field names and file naming as v3.7.1; only shell-injection fixes (`File::Path::make_path`).
- `transports/send_local_smtp` and `send_auth_smtp` (bug 213, v3.8.1): e-mail; they replace `sendEmailScript` and `sendmail2sendEmail` (which needed Brandon Zehm's `sendEmail.pl`), removed in v3.8.1; a config that still names `sendEmailScript` is logged as "Could not find transport script" and skipped. `send_local_smtp` pipes a message to the local sendmail-compatible program (list-form `open`, no shell; no credentials; core Perl only). `send_auth_smtp` uses `Net::SMTP` with optional STARTTLS or implicit TLS (`tls`: `auto`|`yes`|`no`) and `smtpUser`/`smtpPass`; it never sends credentials unencrypted unless `tls: no`, and dies naming the module when `IO::Socket::SSL` or `Authen::SASL` is needed and missing. Both return 1 only on success (a sendmail exit status n is returned as 100+n). Parameters and return codes: `transports/README.md`.
- Then `postRunScript` as v3.7.1.

### 3.6 Config checklist (R1)

- [ ] Legacy-only config at `/etc/camp/sysinfo-client/sysinfo-client.yaml` works unchanged (test with overridden search list).
- [x] `server.info` re-read each run; first-colon split (value `aa:bb:cc` survives); mapping and precedence as §3.3; empty value does not override; tags split.
- [ ] Transport order numeric on keys; first success stops; STDOUT fallback; globals and placeholders exact; no `eval`.
- [x] `grep -n 'eval *["$]'` finds nothing in `sysinfo_client`, `library.pm`, `sysinfoconf.pm`, `transports/`.

## 4. Install and upgrade

### 4.1 Paths

| | Debian/Devuan | FreeBSD | OPNsense |
|---|---|---|---|
| bindir | `/opt/camp/sysinfo_client` | `/usr/local/opt/camp/sysinfo_client` | same as FreeBSD |
| confdir | `/etc/camp/sysinfo_client` | `/usr/local/etc/camp/sysinfo_client` | same as FreeBSD |
| config | `<confdir>/sysinfo_client.yaml` | same | same |
| schedule | symlink `/etc/cron.daily/sysinfo_client` → `<bindir>/sysinfo_client` | symlink `/usr/local/etc/periodic/daily/sysinfo_client` | `actions_sysinfo.conf` in `/usr/local/opnsense/service/conf/actions.d/`, `[reload]` `command:<bindir>/sysinfo_client`, then `service configd restart`; the cron job itself is enabled once in the web UI (System › Settings › Cron, "Collects Systems Info and sends to CAMP") |
| legacy (v3.7.1) | `/opt/camp/sysinfo-client`, `/etc/camp/sysinfo-client/sysinfo-client.yaml`, `/etc/cron.daily/sysinfo-client` | `/usr/local/opt/camp/sysinfo-client`, `/usr/local/etc/camp/sysinfo-client/…`, `/etc/periodic/daily/sysinfo-client` | as FreeBSD, plus the same-named actions file pointing at the dash path |

OS detection (`installer/determineOS`): `opnsense` if `/conf/config.xml` exists, else `freebsd`, else `debian` (Debian, Devuan, MX); anything else: refuse with a message. RPM and IPFire entries removed from `installer_config.pl`.

### 4.2 Dependencies

Required: `perl` ≥ 5.24, `YAML::Tiny` (Debian `libyaml-tiny-perl`, FreeBSD/OPNsense `p5-YAML-Tiny`). Installed automatically (in quiet mode too): first with the OS package manager if it is on the machine (`apt-get install -y`, `pkg install -y`), then, if there is none or it fails, with `cpanm --notest YAML::Tiny`, and last with `cpan -T YAML::Tiny` run with `PERL_MM_USE_DEFAULT=1` so it never prompts (OPNsense has no `p5-YAML-Tiny` package and no `cpanm`, but has `cpan`). Each is tried only if the command exists, and the module is rechecked after each. `installer/setup` loads `YAML::Tiny` only after this step, so a host without it can still be installed; a dry run on such a host reports that the module would be installed and stops. If nothing can install it, setup fails with `RESULT: fail … dependency validation failed`. Optional tools are reported, never installed: `dmidecode` (Linux), `smartmontools` ≥ 7.0, `util-linux` lsblk, `pciutils` (Linux), `ipmitool`, `mdadm`, `lvm2`, `virt-what`. For the `upload_http` transport, `LWP::UserAgent` (Debian `libwww-perl`, FreeBSD/OPNsense `p5-libwww`) and `LWP::Protocol::https` (`liblwp-protocol-https-perl`, `p5-LWP-Protocol-https`) are installed the same way, but failing to install them only prints a warning, since a host may use other transports; `upload_http` itself loads LWP at run time and, when it is missing, says so on STDERR and returns 10 instead of failing to load. Hash::Merge and JSON are no longer required.

### 4.3 Release tarball

- Published files (one current release, fixed names, each URL configurable in config `update` and by command-line option): the tarball `sysinfo-client.tgz`, its checksum `sysinfo-client.tgz.sha256` (new, optional), and the install seed `sysinfo-client.seed.yaml` beside them (already published; a config template with site defaults, used only by fresh installs).
- No version in the file name and no `LATEST` file: the version is read from the unpacked tree (`sysinfo_client --version`).
- `tools/make_release <version>`: `svn export` of `tags/<version>`; builds the tarball from `unix/` only (the Windows zip is built from `windows/`, §7.9; `t/`, `tools/`, `server/`, `Documentation/` and `WorkingNotes/` never ship); drops test scratch files and `reports/`; writes `sysinfo-client.tgz` (single top directory `sysinfo_client/`) and `sysinfo-client.tgz.sha256`. The owner uploads them.

### 4.4 Fresh install (`installer/setup`)

- From a tarball: unpack anywhere, run `installer/setup`; it copies its own tree to bindir. From svn: `svn co <repo>/tags/<version>/unix <bindir>`, run `<bindir>/installer/setup`; the tree is bindir, no copy.
- Steps, each idempotent and logged: determine OS → dependencies → legacy migration if any (§4.6) → install files → config (create from template if absent; `installer/mergeconfig -q` (non-interactive) fills blanks from `server.info`, then live DMI, using `library.pm` placeholder rules) → permissions (§4.7) → schedule entry → `sysinfo_client --version` smoke check.
- `--quiet` never reads STDIN; a quiet fresh install with no `clientName` from seed or `server.info` fails with a clear message. `--seed <path|URL>` (default: config/template `update.seed_url`; fetched with the §4.5 fetch rules), `--dryrun`, `--os` kept. The seed is used only when no config file exists.
- Exit status: `0` installed/upgraded, `1` nothing to do, `≥ 2` failure. Last output line: `RESULT: <ok|nochange|fail> <version> <message>` (for fleet scripting).

### 4.5 Upgrade (`sysinfo_update`, rewritten)

- Non-interactive. Reads config `update`. Method `auto`: `svn` if bindir is a working copy and `svn` exists, else `tarball`.
- URLs: `--url`, `--sha256-url` options override config `update.url`/`update.sha256_url`, and there are no built-in defaults (§3.2): with no URL the updater stops with a message naming the config key to set (`update.url`). The svn method needs no config URL: `svn update` uses the working copy, and `--version` reads the repository from `svn info` (or `--svn-url`).
- `tarball`: fetch `sha256_url` first (it is about 85 bytes): if it exists, it is compared with `<confdir>/update_installed.sha256`, the checksum recorded when the last release was installed; equal (and bindir has `sysinfo_client`) means exit 1 (`nochange`) without downloading the tarball, unless `--force`. If it is absent (404), log a warning and continue (`--require-checksum` makes absence fatal). Then fetch `update.url` (HTTP::Tiny when it can do SSL, else `fetch` (FreeBSD/OPNsense base), `curl`, `wget`; list-form), verify it against the checksum (`Digest::SHA`) and abort on mismatch. A recorded checksum that differs from the published one means the content changed, so the version compare below is skipped and the release is installed even when the version string is unchanged. With no recorded checksum (a host updated before v2.2.0, or never updated) the old rules apply and, on `nochange`, the published checksum is recorded as the baseline; it is recorded after every successful install. Unpack to a temp directory; `perl -c` its `sysinfo_client`; read its version with `--version`. If equal to the installed version: exit 1 (`nochange`) unless `--force`. Else run its `installer/setup --quiet --upgrade`.
- `svn`: `svn update` (trunk working copy) or `svn switch --ignore-ancestry <repo>/tags/<v>/unix` (`--version vX`) in bindir, then `installer/setup --quiet --upgrade`.
- Setup's install step for upgrade: build `<bindir>.new`, move bindir to `<bindir>.previous` (one generation kept, replacing an older one), move `.new` into place, keep `reports/`; if the smoke check fails, restore `.previous` and exit ≥ 2.
- Never touches `server.info`. Log: `/var/log/sysinfo_client_update.log` (append) and STDOUT.
- Scheduling of `sysinfo_update` is not installed by default (owner triggers it over ssh); a commented example cron line is in the README.
- **Bootstrap from v3.7.1** (no new updater on those hosts): owner runs once per host, e.g. `fetch -o /tmp/s.tgz <tarball URL> && tar -xzf /tmp/s.tgz -C /tmp && /tmp/sysinfo_client/installer/setup --quiet` (Linux: `wget -O` instead of `fetch -o`); setup detects the legacy install and migrates.

### 4.6 Legacy (dash) migration

Run by setup whenever a legacy path exists; idempotent; each action logged.

1. If both legacy and new confdir exist and both hold a config file: stop (exit ≥ 2) unless `--force`, which keeps the new one and leaves the legacy directory untouched.
2. Move legacy confdir to new confdir; inside it rename local `modules/` → `sensors/` and `scripts/` → `transports/` if present.
3. Config file: copy `sysinfo-client.yaml` to `sysinfo-client.yaml.v3.7.1.bak`, then rename to `sysinfo_client.yaml`. A `sysinfo-client.conf` (older format) is left in place and logged.
4. Config edits (YAML::Tiny round trip; comments are lost, as with `dsEdit`): in `moduleDirs`/`scriptDirs` replace `sysinfo-client` with `sysinfo_client` in each path, and a final path component `modules` → `sensors`, `scripts` → `transports` for paths under bindir or confdir (other paths untouched); add `update` if absent (template default); add nothing else; never change `clientName`, `serialNumber`, `UUID`, `hostname`, `location`, `os_type`, `tags`, `transports`, `logging`, `postRunScript`.
5. Legacy bindir: after the new bindir passes the smoke check, remove the legacy schedule entry first (so a failure below cannot leave the old cron job running), then move `reports/` content across and remove the legacy bindir. If setup is being run from inside the legacy bindir (e.g. after `svn switch` there), it first changes its working directory to the new bindir, which holds a full copy including any `.svn` working copy; the invoking shell must then `cd` to the new bindir.
6. Schedule: remove `/etc/cron.daily/sysinfo-client` or `/etc/periodic/daily/sysinfo-client`; create the new entry (§4.1). OPNsense: rewrite `actions_sysinfo.conf` in place (same file name and `[reload]` section, so the existing web-UI cron job keeps working), restart configd.
7. Old trunk-era paths are also handled if found: `/opt/camp/sysinfo` (from old `sysinfo_update`) is logged and left alone.

### 4.7 Permissions

`chown -R root:root` (Debian) or `root:wheel` (FreeBSD/OPNsense) on bindir; `sysinfo_client`, `configure`, `dataserialization/dsEdit`, `sysinfo_update`, `installer/*` executables, every file in `sensors/` and `transports/` (name without a dot, not README) → 0700; other files 0600. No trial execution of sensors or transports (applicability is decided at run time by the sensor's exit code). Local files under `<confdir>/sensors|transports` are not changed.

### 4.8 Uninstall

`installer/uninstall [--keep-config]`: removes bindir, `<bindir>.previous`, the schedule entry (and legacy ones), the OPNsense actions file (restart configd); removes confdir unless `--keep-config`. Per-OS paths from `installer_config.pl`.

### 4.9 Install checklist (R3)

- [ ] Fresh install from tarball and from svn on Debian, FreeBSD, OPNsense paths (dry-run output and a temp-root test).
- [ ] Upgrade from a v3.7.1 dash layout (fixture tree under a temp root): config moved, renamed, backed up, path values rewritten (dash→underscore, `modules`→`sensors`, `scripts`→`transports`), `update` added, other keys byte-equal in value; legacy bindir removed only after the smoke check; schedule entry replaced; OPNsense actions file rewritten in place.
- [ ] Re-running setup is a no-op (`RESULT: nochange`); both-configs conflict stops
```
root@rod:~ # cd /usr/local/opt/camp/sysinfo_client/
root@rod:/usr/local/opt/camp/sysinfo_client # installer/setup 
optional dependency ipmitool (ipmitool): missing
optional dependency smartctl (smartctl): present - smartmontools >= 7.0 recommended
install: source tree is bindir, skipping copy
Loading defaults from existing config
Config File written to /usr/local/etc/camp/sysinfo_client/sysinfo_client.yaml
Setting ownership for opnsense as root:wheel
schedule: writing opnsense actions file
Stopping configd...done
Starting configd.
sysinfo_client v3.8.1 (data v3.8.0)
RESULT: ok v3.8.1 installed
```
- [ ] Tarball path: tarball, seed and checksum URLs configurable (config and option); SHA mismatch aborts before any change, missing checksum warns (fatal with `--require-checksum`); failed smoke check restores `.previous`.
- [ ] `--quiet` never reads STDIN; exit codes and `RESULT:` line as §4.4.
- [ ] No sensor or transport executed by the installer; modes per §4.7.
- [ ] No RPM/IPFire/cpan code paths remain; `uninstall` covers all three OSes.
- [ ] All filesystem roots overridable (e.g. `SYSINFO_ROOT`) so tests run unprivileged in a temp dir.

## 5. Tests and tools

- Fixture directories are named by role, never by hostname: `t/fixtures/linux-md-lvm/` (Devuan, md RAID6 + LVM + Xen dom0), `freebsd-zfs/` (FreeBSD 14 NAS: raidz2 data vdev, dedup mirror, zroot mirror on partitions), `opnsense/`, `linux-vm/`, `placeholder-uuid/`, `nvme/` (if available), `install/` (v3.7.1 install layouts), `havirt-node/` (a havirt cluster node: `node list`, `domain list --extended`, `hostname`).
- `t/*.t` (Test::More, `prove -l t/`), no root, no hardware: parser; transport ordering/fallback with stub transports; placeholder substitution; sensor exit codes and timeout with stub sensors; config search and `server.info`; every sensor parser against every fixture; installer/migration against temp-root trees.
- `tools/compare_reports OLD.yaml NEW.yaml`: skip the `#sysinfo:` line; list (a) old key paths missing in NEW, (b) type changes, (c) value differences outside the §1.2 allowed list; exit 0 iff (a) and (b) are empty.
- `tools/make_release` (§4.3).

## 6. Build stages

| Prompt | Implements | Reviewed by (checklists) |
|---|---|---|
| P1 framework | §1.1, client parts of §1.2, §1.5, §2.1–2.6, §3, §5 (parser/transport/config tests, `compare_reports`) | R1: §1.6 client items, §2.8 framework items, §3.6 |
| P2 sensors | §1.2 sensor parts, §1.3, §1.4, §2.6 library additions, §2.7 | R2: §1.6 sensor items, §2.8 sensor items |
| P3 installer | §4, `tools/make_release`, README install sections | R3: §4.9 |

## 7. Windows client (bug 211)

The Windows client is a separate implementation of the same report contract (§1) in Windows PowerShell 5.1, in `windows/`. It is not a port of the Perl code (the Unix client is in `unix/`): it shares the report format, the sensor line format (§2.4), the config key names (§3.2), the `server.info` format (§3.3) and the transport parameter contract (§3.5), and nothing else. The Unix sections above apply only where this section says so.

### 7.1 Scope and rules

- Target Windows PowerShell 5.1 (Windows 10, Windows Server 2016 and later). No PowerShell 7-only syntax. Every script is pure ASCII (5.1 reads a file with no BOM as ANSI).
- The package name in the report is `sysinfo-client-windows`, so CAMP never confuses its version history with the Unix client's.
- Out of scope in v1: email transport, periodic gating, PCI, virtual-guest and SMART sensors, code signing.

### 7.2 Layout and paths

| | Path |
|---|---|
| source | `windows/`: `sysinfo_client.ps1`, `sysinfo_update.ps1`, `dsEdit.ps1`, `configure.ps1`, `install.ps1`, `lib/SysinfoSensor.psm1`, `sensors/`, `transports/upload_http.ps1`, `sysinfo_client.template.json`, `sysinfo-client.seed.example.json`, `README.md`; everything in it ships (a Subversion checkout of `windows/` carries only these) |
| tests | `t/windows/` at the repository root (`run_tests.ps1`, `fixtures/`); never shipped or checked out on a host |
| bindir | `%ProgramFiles%\camp\sysinfo_client` |
| confdir | `%ProgramData%\camp\sysinfo_client` |
| config | `<confdir>\sysinfo_client.json` |
| log | `<confdir>\sysinfo_client.log` (truncated to empty when over 1 MB at the start of a run); the updater's log is `<confdir>\sysinfo_update.log` (same rule) |
| unsent report | `<confdir>\unsent.json` (written only when every transport fails; deleted by the next successful send) |
| server.info | `%ProgramData%\camp\server.info` (format and key mapping as §3.3) |
| test output | `%TEMP%\sysinfo.testing.json` |
| schedule | Task Scheduler task `\camp\sysinfo_client`, daily, as SYSTEM |

### 7.3 Client (`sysinfo_client.ps1`)

- Parameters: `-ConfigFile <path>`, `-Test`, `-Version`, `-Help`, `-LogLevel <0-5>`.
- Config: JSON only, searched as `-ConfigFile`, `<confdir>\sysinfo_client.json`, then the script's own directory. Keys and meaning as §3.2, same names including the `'key for ...'` transport keys. `outputType` is ignored (always JSON); `update` is read only by `sysinfo_update.ps1` (§7.7) and is never copied into `system`. Keys not in the excluded list are copied into `system`, as on Unix.
- `server.info` is read every run and overrides config values as §3.3. Hostname default: the lower-cased FQDN.
- A real run must be elevated (SYSTEM or Administrator) and needs a `clientName` (from the config or `Owner:` in `server.info`); without one it logs, prints a hint and sends nothing. `-Test` needs neither, skips the ACL check and calls no transport.
- Sensor discovery: each `moduleDirs` entry (default `<bindir>\sensors`), files named `^[A-Za-z0-9][A-Za-z0-9_]*\.ps1$`, sorted, first name wins. ACL check instead of the Unix 0700/root check, on the file, its directory and `..\lib\SysinfoSensor.psm1`: the owner must be SYSTEM, Administrators or TrustedInstaller, and no ACE may grant Write, Modify, FullControl, ChangePermissions or TakeOwnership to anyone else. A failure gives status `skipped_permission`. The same check applies to transport scripts before they are loaded.
- Sensor execution: a child `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File <sensor> "<dir>\"`, STDOUT and STDERR read asynchronously, `SYSINFO_*` environment as §1.3, timeout `sensorTimeout` (default 120 s) enforced with `taskkill /T /F` (status `timeout`). Exit codes and statuses exactly as `unix/sensors/Documentation/README.md`; STDOUT is parsed only on exit 0, with the §2.4 rules.
- Report: as §1 with `report.client`, `report.date` (`yyyy-MM-dd HH:mm:ss`, local), `report.version` = data version `v3.8.0`; `system.hostname`, `system.serial`, `system.UUID` plus the config keys; the `sysinfo` section as §1.5 (`client_version` is the Windows client's version); `software.sysinfo-client-windows.version`. Written with `ConvertTo-Json -Depth 20`, no BOM, one-element lists kept as arrays.
- Transports: the `transports` map in numeric key order; `sendScript` is `<name>.ps1` in `scriptDirs` (default `<bindir>\transports`); each defines `Send-SysinfoReport($Params, [string]$Report)` returning an int; the first return of 1 wins; globals and placeholder substitution as §3.5. If all fail, the report is saved as `unsent.json` and the run exits 1. A missing transport script is logged and skipped (so a seed written for Unix hosts, with an email transport, still works).

### 7.4 Transport `upload_http.ps1`

Port of `unix/transports/upload_http`: the same parameter keys, return codes (1 sent; 4 failure; 5-9 missing key) and POST field names, file part `temp.report`, filename `<report date>_<client name>_<host name>_<serial number>.sysinfo.json` with whitespace runs turned into `_`. TLS 1.2 is forced. Success is any 2xx; redirects are not followed. The `URL` must start with `http://` or `https://` (otherwise return 4 with a message saying so).

### 7.5 Sensor library (`lib/SysinfoSensor.psm1`)

`Write-SysinfoFact -Path @(...) -Value <v>` (replaces tab, CR and LF with a space), exit-code constants, `Test-SysinfoWindows`, `Invoke-SysinfoSensor` (an uncaught error exits 4), `Exit-SysinfoSensor`, `ConvertTo-NormalisedUUID`, `Test-PlaceholderUUID` and `Test-PlaceholderSerial` (the lists of `unix/library.pm`, §1.3 rules U and P).

### 7.6 Sensors (one thing each, one OS)

Every sensor exits 1 off Windows, 2 if the CIM class or cmdlet it needs is missing, and 4 or more with a one-line STDERR reason on failure. All values are strings.

| Sensor | Keys | Source |
|---|---|---|
| `windows_identity` | `identity.*` as §1.3 (`system_uuid_raw`, `system_uuid`, `system_uuid_status`, `system_serial`, `system_serial_status`, `board_serial`, `system_manufacturer`, `system_product`, `machine_id`, `virtualization`, `configured_*`, `server_info_*`, `uuid`, `uuid_source`, `serial`, `serial_source`) | `Win32_ComputerSystemProduct`, `Win32_BIOS`, `Win32_BaseBoard`, `Win32_ComputerSystem`, registry `MachineGuid`; `virtualization` inferred from manufacturer and model (`kvm`, `hyperv`, `vmware`, `xen`, `virtualbox`), else `none` |
| `windows_system` | `system.last_boot` (Unix epoch, UTC), `system.uptime` (seconds); `operatingsystem.os_name` = `Windows`, `.description`, `.os_version`, `.kernel` (version and UBR), `.release` | `Win32_OperatingSystem`, registry `CurrentVersion` |
| `windows_storage` | `diskinfo.<letter>`: `fstype`, `size`, `used` (KiB), `mount` | `Win32_LogicalDisk`, `DriveType = 3` |
| `windows_network` | `network.<InterfaceAlias>`: `address`, `netmask`, `ip6address`, `ip6networkbits`, `mac` (lower case, colon-separated), `mtu` | `Get-NetAdapter` (Up, not loopback), `Get-NetIPAddress`, `Get-NetIPInterface` |
| `windows_software` | `software.<DisplayName>.version`, `.description` (Publisher) | registry Uninstall keys, 64- and 32-bit views; skips entries with no name, `SystemComponent`, a parent key, or an update type; never `Win32_Product` |

There is no `pci`, `xen` or `attributes` on Windows. The old CAMP importer reads `operatingsystem.*`, `system.last_boot`, `network.*`, `software.*` and identifies by `report.date`, `report.client`, `system.hostname` and `system.serial`; `diskinfo`, `identity` and `sysinfo` are ignored by it and kept for campv2.

### 7.7 Install and configuration (`install.ps1`, elevated)

- Install or upgrade (default): copies `sysinfo_client.ps1`, `sysinfo_update.ps1`, `dsEdit.ps1`, `configure.ps1`, `lib`, `sensors` and `transports` to bindir, replacing the previous copy (renamed to `sysinfo_client.old` first and restored if the copy fails, so a locked directory changes nothing; removed once the copy succeeds); locks bindir and confdir (inheritance off; SYSTEM and Administrators full control, nothing else; owner Administrators) and `%ProgramData%\camp` (the same, plus Users read, so nobody can plant a `server.info`); writes `sysinfo_client.json` only when none exists (template, then seed, then parameters, plus the BIOS serial and UUID with placeholders stored blank, hostname, `os_type` = `windows`); registers the task (SYSTEM, highest run level, daily at a random time between 01:00 and 05:00, kept on upgrade, `StartWhenAvailable`, runs on battery, 1 h limit); checks the client's ACL rule against every installed file; runs the client once with `-Test`. Ends with `RESULT: ok|fail <version> <message>`; exit 0 on ok.
- `-ClientName`, `-Location`, `-Seed <path|URL>` (a JSON seed of site-wide settings: transports, logging; never client, location or tags), `-Quiet`, `-DryRun` (prints the actions and the config, changes nothing).
- A missing client name does not block the install: it is asked for (not with `-Quiet`), and when still unknown the install finishes with a warning and the client sends nothing until a name exists.
- `-Configure` edits client name, location and tags of an installed config without reinstalling: with `-ClientName` or `-Location` it applies them silently, otherwise it prompts showing the current values.
- `-Uninstall` removes the task and bindir and keeps the config; `-Uninstall -Purge` removes it too.
- `install.ps1` also copies `sysinfo_update.ps1`. `-UpdateUrl` and `-UpdateSha256Url` set config `update.url` and `update.sha256_url` (over the seed's `update`, which a new config takes like any seed key); with `-Configure` they are applied to an existing config, and with no other change asked for, `-Configure` does not prompt.
- Upgrade by hand means unpacking the new release (or updating the svn checkout) and running `install.ps1` again; `sysinfo_update.ps1` does the same from the release zip (§7.10).
- Bootstrap: a host installed before the updater existed has none; run the new release's `install.ps1` once (with `-UpdateUrl`/`-UpdateSha256Url` on an existing config, since an existing config is never changed otherwise).
- `server.info` is not ACL-checked by the client: a writable `server.info` can change the reported client name, hostname, serial and UUID. The installer's lock on `%ProgramData%\camp` is the only protection.

### 7.8 Tests

`t/windows/run_tests.ps1` is a plain script (no Pester), printing one `PASS`, `FAIL` or `SKIP` line per check and a final `RESULT:` line. It runs under PowerShell 5.1 on Windows (all checks) and under `pwsh` on Linux (Windows-only checks are skipped). It covers the §2.4 parser cases, the sensor runner (fixture sensors for each exit code, garbage output, timeout), the report (no BOM, arrays, nested depth, required keys), `server.info`, the ACL check, the transport return codes and filename, `unsent.json` cleanup, and an installer dry run. The tests read `windows/` from the repository root, so run them from a checkout or export of the whole repository, not from an installed copy; `windows/` itself holds no tests.

### 7.9 Release

`tools/make_release` writes `sysinfo-client-windows.zip` (single top directory `sysinfo_client_windows/`, the contents of `windows/`) and its `.sha256` beside the Unix tarball (§4.3), built with `python3 -m zipfile`. The owner publishes the zip, the checksum and a `sysinfo-client.seed.json` beside the existing files. The zip is the primary install route, and the file `sysinfo_update.ps1` fetches (`update.url`); a Subversion checkout of `windows/` is the backup (`windows/README.md`).

### 7.10 Update (`sysinfo_update.ps1`)

The Windows counterpart of §4.5, a `.zip` where Unix uses a `.tgz`. Elevated and non-interactive (`-Check` needs no elevation); parameters `-Url`, `-Sha256Url`, `-Config`, `-Check`, `-Force`, `-RequireChecksum`, `-Quiet`. Its own parameter names differ from the client's because it dot-sources `sysinfo_client.ps1` for the config, JSON and elevation functions.

- URLs: `-Url`/`-Sha256Url`, else config `update.url`/`update.sha256_url`; a URL may also be a local path. There are no built-in URLs (§3.2): with none the updater stops and names `update.url`.
- Fetch with `System.Net.WebClient` (TLS 1.2). The checksum file is verified with `Get-FileHash`: a mismatch aborts; a missing file (404) is a logged warning and the update continues, unless `-RequireChecksum`; any other fetch failure aborts.
- Unpack with `System.IO.Compression` into `<confdir>\update_tmp\u_<guid>` (locked with the config directory), after refusing any entry that would land outside it; the directory is removed at the end. The release directory is the one holding `sysinfo_client.ps1` and `install.ps1`.
- The offered version is read from the text of the new `sysinfo_client.ps1` (`$script:CLIENT_VERSION`, compared as `[version]`), never by running downloaded code first. Equal or older: `RESULT: nochange`, exit 1, unless `-Force`. `-Check` reports `update available: <old> -> <new>` and installs nothing.
- Otherwise run the new release's `install.ps1 -Quiet` in a child PowerShell and report its result. The config, `server.info` and the scheduled time are kept.
- Output: `RESULT: ok|nochange|fail <version> <message>`; exit 0, 1, 2. Log: `<confdir>\sysinfo_update.log` (append) and STDOUT.
- Scheduling is not installed (as on Unix); `windows/README.md` has an example `schtasks` line. No svn method.
- [ ] Tests (`t/windows/run_tests.ps1`): checksum parsing, version read and compare (`1.10.0` newer than `1.9.0`), settings precedence and no built-in URLs, unpack (good zip, missing installer, escaping entry), and on Windows `-Check` end to end (newer, same, checksum good/bad/absent, `-RequireChecksum`, no URL).

### 7.11 Config editor (`dsEdit.ps1`, `configure.ps1`; bug 216)

A Windows PowerShell 5.1 tool so technicians edit the JSON config (or `server.info`) without syntax mistakes. It is a separate implementation of the idea behind the Unix `dsEdit` (§3.4), not a port: exact parity is a non-goal, the Perl `DataSerialization.pm` cannot be used (no Perl on Windows, bug 211), and dsMerge has no Windows version. `configure.ps1` is a thin wrapper that runs `dsEdit.ps1 -ClientConfig` with every parameter passed through.

- Formats: JSON and INI. The format comes from the extension (`.json`; `.ini`, `.conf`, `.info`) or `-Format`; `server.info` is INI with the delimiter `:` (`-Delimiter` overrides). YAML is refused with a message.
- File: first positional parameter or `-Path` (alias `-File`); relative paths start at the current directory and `%VARIABLES%` are expanded, so a desktop shortcut may name any file. With no file: `-ClientConfig` means `<confdir>\sysinfo_client.json`; otherwise the existing client config and `server.info` are offered.
- Interactive navigator (no `-Set`, `-Add`, `-Remove` or `-Show`): `Read-Host` based, so it works in the ISE and a plain console. The file is a tree; numbers open a map or list or change a value (kept as its type: text, number or true/false), `a` adds, `d N` deletes, `u` goes up, `w` writes, `s` writes and quits, `q` quits (asking when changes are unsaved), `v` shows the file, `?` helps. A new entry may be text, number, true/false, list or map (INI: text, or a top-level `[section]`).
- Scripted: `-Set key=value`, `-Add key=value` (append to a list unless already present), `-Remove key` or `-Remove key=value` (a list item); dotted paths (`update.url`); an existing value keeps its type, a new one is a number or true/false only when it looks like one (leading zeros stay text); `-Show` prints the result. From `cmd` or a shortcut (`-File`) each of these takes one value per run; several values need an array (`-Set a=1,b=2`) inside a PowerShell session.
- The structure is serialized by dsEdit itself (3-space indent, no depth limit, `<` and `>` not escaped, LF or the file's own CRLF), re-parsed and compared before anything is written; the previous `ConvertTo-Json -Depth` problem (silent truncation) cannot occur. A JSON file that does not parse is reported (with the parser's message) and never overwritten.
- Writing: UTF-8 without a BOM to `<file>.new` beside the original, read back and compared, then `[IO.File]::Replace(new, file, file.bak)`: atomic, keeps the ACL of the original, and the backup (which may hold transport secrets) is the original file with that same ACL. `-LiteralPath` is used throughout. Exit 0 done, 2 failed.
- Known-key check (client config only: a file named `sysinfo_client*.json` or with a `clientName`): types of `clientName`, `location`, `hostname`, `serialNumber`, `UUID`, `os_type` (text), `tags` (list of text), `sensorTimeout`, `TESTING` (number), `logging`, `transports` (each a map), `update` (map). A mismatch warns and asks "Write it anyway?"; from the command line it refuses unless `-Force`.
- Elevation: a file that cannot be opened by an unelevated user (the locked `C:\ProgramData\camp`) makes dsEdit start an elevated copy of itself (`Start-Process -Verb RunAs`, a UAC prompt, the arguments carried in one `-EncodedCommand`) and exit; the new window pauses at the end. `-NoElevate` turns this off. `-Pause` keeps a window open until Enter (desktop shortcut).
- Desktop shortcut: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Program Files\camp\sysinfo_client\dsEdit.ps1" -Pause "<file>"` edits that file; `configure.ps1 -Pause` edits the client config. `install.ps1 -DesktopShortcut` creates "CAMP sysinfo config" (`configure.ps1 -Pause`) and "CAMP sysinfo update" (`sysinfo_update.ps1 -Pause`) on the desktop of the user running the installer, or the public desktop with `-ShortcutAllUsers`; both carry the Run as administrator flag (bit 0x20 of byte 0x15 of the .lnk) because the program directory is unreadable to an unelevated user, so the self-elevation above is only a fallback for an administrator's filtered token. `-Uninstall` removes them from both desktops.
- Comments: INI comment lines before the first entry are kept; other comments, blank lines and lines without a delimiter are not (the tool says how many). INI values are text and keys keep their order.
- [ ] Tests (`t/windows/run_tests.ps1`): serializer round trip (depth, escapes, types, empty list and map), refusal of invalid JSON, `-Set`/`-Add`/`-Remove` rules, known-key check, INI (colon, CRLF, sections, header), format detection, file write (BOM, CRLF, `.bak`, no temp file), relaunch command, and end to end in a child PowerShell (operations, refusals, navigator through stdin, `configure.ps1` pass-through). The real elevation and ACL behaviour and PowerShell 5.1 output need a Windows host.
