fog-agent: enrollment and authenticated channel (server side) - #1707
Draft
mastacontrola wants to merge 116 commits into
Draft
fog-agent: enrollment and authenticated channel (server side)#1707mastacontrola wants to merge 116 commits into
mastacontrola wants to merge 116 commits into
Conversation
Server side of the fog-agent replacement for the FOG client. Testing
only at this stage; nothing here is reachable from the existing client.
Enrollment (POST /agent/v1/enroll, unauthenticated):
- agentEnrollment / agentEnrollToken tables and four hostAgent* columns
(schema step 416, manifest, FK map group 12, route-column contract).
- FOG\Agent\Enrollment matches the SMBIOS identity and CSR key against
existing hosts, pends unknown or rebinding machines for an admin, and
auto-approves via a minted token or an active deploy task.
- fog-sign-node-cert gains an `agent` type: clientAuth-only leaf signed
by a new agent intermediate CA, CN carries the host id, no names.
- Admin routes: GET /agent/enrollments, POST /agent/enrollment/{id}/{action}.
Authenticated channel (client certificate):
- FOG\Agent\Principal re-verifies the presented certificate in PHP
against management/other/agent-ca-bundle.pem (X509_PURPOSE_SSL_CLIENT)
and binds it to a host by SPKI fingerprint with a direct prepared
statement. Route::getIds() cannot be used here: it adds the calling
user's site scope to the WHERE, and with no user that is `1=0`.
- Route gates every /agent/v1/* path except enroll on that principal
(401 JSON otherwise); POST /agent/v1/poll records version and check-in.
- Installer: agent CA bundle, nginx ssl_verify_client optional with
SSL_CLIENT_VERIFY / SSL_CLIENT_CERT params, Apache SSLVerifyClient
optional with ExportCertData. Apache path is untested (lab is nginx).
Fixes found on the way:
- Host::addMAC() before save() wrote hostMAC rows with an empty hostID;
reordered in Enrollment and both new-host sites in Boot\Registration.
- BootFileManager->find() in FOGPage::_bootFileRow (a 1.5 API, swallowed
by a catch) replaced with getIds().
- PHPStan extension build/phpstan/GetClassReturnTypeExtension.php types
getClass('Name') so a wrong method name on a manager is a finding;
the 16 pre-existing findings it surfaced are baselined for a later pass.
Proven on the lab 2026-09-03: enroll -> pending -> approve -> issued ->
poll with the certificate updates the host row. tests/agent-principal
.test.php and the existing suites pass (252/252); phpstan clean.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Hosts > Pending Agents: the admin side of fog-agent enrollment, sibling of Pending Hosts and Pending MACs and built on the Pending MACs shape (HostManagement::pendingAgents / pendingAgentsAjax / getPendingAgentList, fog.host.pendingAgents.js). Select rows, Approve or Deny with a confirm modal; each decision runs through FOG\Agent\Enrollment, the same code the JSON route uses. The dashboard gets a "Pending agents" alert beside the pending hosts and MACs ones. The grid is client-side over the same whitelisted payload GET /agent/enrollments serves, not Route::listem(): agentenrollment is deliberately not an API class, since every row carries a CSR and, once approved, a certificate. The list is bounded by what an admin has not yet looked at, never by the fleet. Permissions fall out of Authorization::_subToAction unchanged: the page is host.view, the POST is host.edit, the list source is host.view. Also: the first commit left two suite gates red that the earlier run did not cover. psr4-scan now places Agent\Enrollment and Agent\Principal (both extend FOGBase directly, so ancestry cannot), and all-classes-load skips build/, which is PHPStan tooling loaded by the root composer autoload-dev and implements interfaces FOG's own autoloader has no way to declare. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Renewal: POST /agent/v1/renew, over the certificate being renewed. The
same gate as poll binds the caller to its host; the body carries a CSR
for the same key, and the answer is the enroll "issued" shape. Same key
only: a different key is a new claim on the machine and goes through
enroll and an admin. Enrollment::renew() signs through the existing
helper, moves hostAgentNotAfter and audits.
Tokens: FOG\Agent\Token mints, lists and revokes enrollment tokens (the
credential that lets a machine enroll without an admin clicking, design
0001 agent-based registration). The token is a 48-hex-character secret
shown exactly once; only its sha256 is stored. An expiry is required;
uses count down, or -1 is unlimited until expiry. Routes GET
/agent/tokens (host.view), POST /agent/token (host.create), DELETE
/agent/token/{id} (host.delete). Page Hosts > Agent Tokens, on the
Pending Agents shape, with the mint modal handing the token over once
and the ajax subs named create*/delete* so the permissions derive from
the names. Audited as agent.token.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
The convergence half of the protocol (design 0001 section 2). The poll now lists the capabilities this server offers the host and the revision of its desired state; GET /agent/v1/state returns that state and POST /agent/v1/result records what a provider did with it. Same certificate gate as poll. A capability is listed when its legacy module is on for the host: the global FOG_CLIENT_*_ENABLED setting and the host's resolved module set, the two checks the old client's endpoints make, so existing per-host and per-group module choices carry over unchanged. The first capability is hostname: the host record's name and its enforce flag. The revision is a digest of the state, so "anything changed?" costs the poll one compare. Results are agent.result audit rows on the host, where FOG already shows what happened to a host; no table until inventory needs one. Writes the client certificate authorized carry authSource 'agent', not anonymous (renewal corrected to match). Also: the Agent Tokens page's mint and revoke posts fell through to the host list, because FOGPageManager appends the Ajax suffix only after method_exists() passes for the bare name. Both handlers now have their bare twin. Found by the first browser run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
A host created by an agent enrollment had no module rows, so State::capabilities() resolved to [] and the agent never received the hostname capability even though FOG_CLIENT_HOSTNAMECHANGER_ENABLED was on. Resolver::resolveModules has no default tier: a host only has the modules explicitly attached to it or granted through a group. Match Boot\Registration and HostManagement::addPost by attaching the isDefault modules at creation. Also clear the token name field when the mint modal opens, so the second token does not silently inherit the first one's name. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Desired state gains a `task` block (capability taskreboot, module taskreboot): the task waiting for the host in a state that needs it to boot into FOS, the same answer Client\Jobs gives the old client, with FOG_TASK_FORCE_REBOOT as its force flag. Present only while one waits, so queueing or canceling a task moves the revision. A `reboot` block carries FOG_GRACE_TIMEOUT with any non-empty capability list. The agent's reboot coordinator reports its decisions as results with capability `reboot`, so results now accept that alongside the capabilities proper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
On a server with sites configured, every Route::getIds on a scoped node inside an /agent/v1/ request answered empty: the site boundary asks which objects THIS USER may see, an agent request has no user, and Authorization only lifts the boundary for an entry point that declares FOG_MACHINE_REQUEST, as every service/*.php does. The agent's desired state therefore carried task: null with a task queued, and group-granted modules would have dropped out the same way. Declare it for the prefix, after _agentPrincipal() has bound a host and after the 401 for an unbound one, so it stays a positive statement about the entry point and a route that lost its 401 still would not get it. Guard (k) in route-read-path-guards anchors the block and fails if the declaration is removed or moved above the 401. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Capability `snapin` (module snapinclient) puts the host's snapin queue in
the desired state exactly as the server tasked it -- snapinTasks in
sequence order, from the resolver's host-first, then groups, deduplicated
list -- with each task's file, size, sha512, arguments, interpreter,
timeout, reboot or shutdown flag and the job's abort-on-fail. Two routes
serve it: GET /agent/v1/snapin/{id}/file streams the payload from the
storage node over the web tier's own FTP session and marks the task in
progress; POST /agent/v1/snapin/{id}/result closes it with the exit
code and output tail, cancels the rest of a job that aborts on failure,
ends the job after its last task, and audits agent.result on the host.
Both check the task belongs to the host's own job, the legacy Aisle 009
guard, with one message for "missing" and "not yours".
Agent\Snapins::stream() and close() ARE the legacy SnapinClient's
_downloadfile and _closeout bodies; the legacy methods keep their input
parsing and call the shared code, so both clients mark tasks and end
jobs identically and cannot drift.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Snapins reported success only when the payload exited 0, which mislabels the codes installers actually return: 3010/1641 (installed, reboot to finish) and 1618 (another install in progress, retry) both read as failures and could trip abort-on-fail. This gives each snapin a `code=class` table (sReturnCodes, empty = the Intune defaults 0/1707=success, 3010/1641=reboot, 1618=retry) and derives an outcome from it on the server: - retry puts the task back to queued so the next check-in runs it again - reboot returns the outcome to the agent, whose coordinator handles it - abort-on-fail only fires on failed, not on reboot/retry snapinTasks gains stStatus (ran/hash_mismatch/timeout/cannot_run) next to the raw exit code, so a payload that never ran is no longer recorded as exit code 0, and stReturnDetails widens to TEXT so a 4 KB tail of the payload's output fits. Schema 417. UI: Return Codes textarea on snapin add/edit, Status column in the host and group snapin history, report page labels outcomes from stStatus. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Generated from a live install after the 417 update: sReturnCodes, stStatus and the TEXT stReturnDetails, plus the foreign-key backing indexes the reconciler's constraint pass created on that install, which the generator keeps by design. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Linux and macOS truncate an exit status to 8 bits, so 3010 and 1618 cannot be returned there; the help text now says to list the code the program can actually return. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
… first
Design 0003 (fog-agent docs/design/0003-software.md). A software entry is
a package id plus a version policy (any, latest, pinned) and a state
(present, absent), assigned to hosts directly and granted to groups,
resolved per host in the snapin order (direct, then groups in group
order, deduplicated). The agent's `software` capability converges the
set and reports per entry; the server reads the exit code against the
entry's return-code table (snapin defaults plus Chocolatey's 350 as
reboot), refreshes one status row per host and entry, and answers the
outcome. Nothing here is a task; snapins are untouched.
Schema 418: software, softwareAssoc, groupSoftwareAssoc, softwareStatus,
module 13 `software`, FOG_SOFTWARE_DRIFT_INTERVAL (six hours), and the
stReturnDetails default 417 left off. Manifest entries added by hand in
the generator's shape until a migrated database can regenerate it.
UI: Software node (list, add, edit with General, Hosts and Status tabs),
Software tab on host and group edit with run order, Software Status tab
on the host, Software Report. Route /agent/v1/software/{id}/result.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
…TEXT defaults 418 seeded the software module at id 13, which step 223 had given to powermanagement, so INSERT IGNORE dropped it and the capability was never offered. 419 inserts by short name with whatever id is free, adds FOG_CLIENT_SOFTWARE_ENABLED (and lists the module in getGlobalModuleStatus), and re-applies the stReturnDetails and sstDetails defaults for a server that ran 418 before they were added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Same entry the snapin result route has; without it the user-permission layer answers 403 to every report the agent sends. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Two FOG Client settings, both empty by default, sent to the agent in the software block as `bootstrap`: FOG_SOFTWARE_CHOCO_BOOTSTRAP_URL, the install script the agent fetches and runs as SYSTEM on a host that has software assigned and no Chocolatey, and FOG_SOFTWARE_CHOCO_NUPKG_URL, the package it installs from for hosts with no route to the community feed. Off by default because the fetched script runs as SYSTEM: an admin opts in by naming it (fog-agent design 0003 section 8, agent c14b886). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Capability `power` on module powermanagement (fog-agent design 0004). The desired state carries the host's resolved shutdown and reboot schedules, the way Client\PM hands them to the legacy client minus `wol`, which the server keeps sending itself, plus the host's pending on-demand rows. The agent acknowledges an on-demand action with a `power applied` result and that report consumes the rows, where the legacy client consumed them on read: a request the agent never received stays standing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
…Route::getIds
The software module (schema 419) is agent-only, but requestClientInfo()
mapped its short name to Items\Software through the default branch and
every legacy fog-client check-in fataled on the missing json().
_bootFileRow() called self::getIds(), which FOGPage does not have, so the
map stayed empty and every host and group page re-read and re-hashed every
boot file. Same fix as working-1.6 is shipping: Route::getIds('bootfile',
false), and the baseline count for that call shape goes to 2.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Every item report on /agent/v1/result carries the provider's output in `details` -- a snapin's tail, a package manager's log -- and a printer's failure message is the same thing: lpadmin's own words. PrinterSet was reading `error`, a spelling nothing else on the route uses. Caught wiring the agent half up to it: the first real end-to-end run posted `details` and the message went nowhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
`Client\HostnameChanger::json()` puts ADUser and ADPass in the answer to every check-in of every host with useAD set -- joined or not, forever. A joined estate is one where every machine permanently holds a credential that can create computer objects in the directory, for no reason: it is already joined. `FOG\Agent\DirectoryJoin` sends it only to a host the server believes is unjoined, only while that is true, and not again for an hour after an attempt. Null is returned -- no block at all -- when the host is not set to use AD, names no domain, has never reported its membership (the server does not know, and a credential is not something to send on a guess), is already in the right domain, is joined to a different one (the agent would refuse, so sending it exposes the account for nothing), or is cooling. The cooldown is not politeness. A join that fails on a bad password is a failed authentication against a domain controller, and one per host per poll is how a service account with a lockout policy gets locked out, taking every other host's join with it. The result rides the ITEM half of /agent/v1/result rather than the plain one, because the join has its own vocabulary -- joined, already_joined, failed, unsupported, refused -- and `failed` means two different things in the two places. The item is the host's own membership row, addressed by host id, and a host reporting on somebody else's gets a 404. hostDirectory gains hdJoinAt and hdJoinError so an outcome has somewhere to live; FOG has never recorded a join result at all. Both surface in the Directory Membership report's new Join column, and a settled status clears a stale error -- an admin chasing a message against a machine that is now joined is worse off than one chasing none. DirectoryPlacement::decodeStored() is made public rather than copying the three-shape base64 dance a third time. The legacy client's copy keeps its non-strict-base64 bug: changing what the legacy client is sent is a separate and riskier change. Proved on the live install by prove_directory_join.php -- 20 checks through State::desired() and State::result(), including that turning the hostnamechanger module off stops the credential entirely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
A magic packet is a link-layer broadcast, so FOG can only send one from a machine it owns. `FOGBase::wakeUp()` already fans out to every enabled, online storage node, which covers every link FOG has a machine on -- and in a routed estate a subnet routinely has FOG hosts on it and no FOG server or node at all. The documented answer, a directed broadcast, has been off by default on enterprise routers since the smurf attack, and asking a security team to re-enable it is asking them to undo a decision that was right. The sender that was always there is a machine already ON that link, already awake, already authenticated. `FOG\Agent\WakeRelay` finds it and asks it, ADDITIONAL to the node fan-out rather than instead of it. Two controls, because a magic packet is unauthenticated by construction: the server picks both ends -- a target is a row in `hosts` and its MACs are that host's own rows, so there is no path from an arbitrary MAC to the wire -- and the agent is never told WHERE to send. The block carries host ids and MACs and no destination at all; the agent broadcasts on its own interfaces. An agent that accepted a destination would be a UDP reflector for whoever could feed it one. Finding the neighbor needed a fact FOG has never held: a host's interfaces. hostIP is one address with no prefix and no interface behind it, so "which machines share a link with host 41" has not been a question this server could answer. `hostNetwork` records what the machine reports about its own links and stores the network address alongside the prefix, which makes that an index lookup rather than a scan. The server recomputes both from the address and prefix and discards what the agent sent -- a host that could claim a network it is not on could join any link's relay group it liked. A candidate sender is on the same network AND prefix, has the interface up and running, has a broadcast address at all, is not wireless (an access point will not bridge a broadcast to a station that is asleep and so not associated), has checked in recently enough to be awake, and is not the target. Three are asked, because one datagram costs nothing and the alternative is a wake that does nothing because the single sender went to sleep. Requests expire, so a wake is never a standing instruction. The result is the first time FOG can say anything about whether a wake happened -- the existing path is fire and forget, and a machine that stayed asleep is indistinguishable from a packet that never left the building. The pending row is also the authorization: this is the only item report whose id is another host's, and a result with no pending row naming this sender and that target is a 404. Pending MACs are excluded, the way `Group::wakeOnLAN()` already does and `Host::wakeOnLAN()` does not. That is a deliberate behavior difference confined to the new path; narrowing the old one is a separate change. Off by default (FOG_AGENT_WAKE_RELAY_ENABLED): this asks one customer machine to put traffic on the network for another. Proved on the live install by prove_wake_relay.php -- 25 checks through the real entry points -- and end to end by prove_wake_relay_on_the_wire.php, which caught a real gap: four magic packets went out and the row recorded packets=0, because the agent had no field to put the count in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
hosts.hostSbState is written by iPXE on every PXE boot, which is the right place for it -- iPXE runs whenever a machine netboots, where FOS runs only when someone schedules a task. But a machine that boots its own disk never netboots, so its value is frozen at whatever it said the last time anybody imaged it, and the staleness runs in the dangerous direction: `disabled` is the value that makes a host look like a valid enrollment target, and it is exactly what a machine leaves behind on the last netboot before it starts enforcing. Measured on host 105 telliottwin11, which the ledger called `disabled` while Confirm-SecureBootUEFI answered True. A second reporter for the same column, not a second vocabulary. The agent sends the same three raw values iPXE sends -- platform, the SecureBoot byte, the SetupMode byte -- and SecureBootFacts maps them with SecureBootState::fromBootRequest(), the very call the boot path uses. Letting the agent send a computed name would put the six-way mapping in two codebases in two languages, which is the drift the vocabulary was copied verbatim from FOS's sbState() to avoid. A registry entry and a block in the poll, never a route of its own (the route rule). facts() dispatches FACT_REPORTS generically and answers want_secureboot on its own, and hfsKind is varchar(16), so nothing else had to change. Still advisory (ADR 0029). An agent report arrives over an enrolled mTLS channel, so the server knows whose certificate asserted it -- that is attribution, not trust. A compromised OS can lie about its own firmware. Two things the tests caught before this shipped: - HostManager::update() takes item PROPERTY names. perform_update() looks each key up in Host::$databaseFields, so 'hostSbState' resolved to nothing and built an UPDATE with an empty column. It wants 'sbstate'. - The first version of the test read the SQL for the string "hostSbState" and passed against exactly that bug, because the bind placeholder is named :update_<field> from the property name. It now reads the column, and "writes nothing" means no UPDATE at all rather than no RECOGNIZED state -- the earlier form let an UNKNOWN write through, which is the one write the guard exists to prevent. Five mutants killed: the column name, the UNKNOWN guard, collapsing an absent key to '', ignoring setup_mode, and a client-supplied timestamp. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
The host form showed "Last Client Check-In" -- hostLastCheckin, written by the legacy FOG Client. fog-agent writes a different column, hostAgentCheckin, on every poll in Route::agentPoll(), and nothing rendered it anywhere. So a host running only the agent showed "Never", or a real date from months ago, for a machine that had polled a minute earlier. Two clients, two columns, one of them displayed. The agent replaces the legacy client, so the form now shows the agent's clock and the legacy field is gone from it. hostLastCheckin itself is untouched: the legacy client still writes it and the host list still carries a column for it, which is what a site running both during a migration needs. agentCheckin joins host's serverOwnedFields. It belongs there for the reason lastcheckin does -- a caller writing it asserts an event that did not happen -- and for one more that lastcheckin does not have: WakeRelay picks which hosts are fresh enough to relay a wake by this column, so a host able to write its own heartbeat could nominate itself as a relay for a subnet it is not on. The existing test checked a subset of the list by hand, so the new entry was not covered until it got its own assertion; removing it from the list now turns that assertion red. Two test gates this tripped, both fixed rather than worked around: - psr4-layout: SecureBootFacts had no home in bin/psr4-scan.php's TABLE. Added with the reason it is not called SecureBootState -- that name is the Boot class holding the six state names, and this only reports into that vocabulary. - utc-storage-boundary: it counts dateOrNever() calls that name their table and column, and its "hinted" pattern accepted only a lowercase property name. 'agentCheckin' is camelCase in Host::$databaseFields, so a correctly hinted call was reported as unhinted. Widened to [A-Za-z]; dropping the hint from the new call still turns it red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
Found by reading the first real report on the lab server, not by a test: agent reported Secure Boot Secure Boot ON (was Secure Boot OFF) SecureBootState::label() already spells the words where they belong, so the sprintf prefix duplicated them. Dropping it also reads correctly for the labels that do not carry the words -- "agent reported UEFI, state unreadable". The 13 checks in this file all passed straight through that, because none of them looked at the audit line -- the thing an admin actually reads. Four checks now do: that a line is recorded, that it does not repeat itself, that it names both ends of a transition, and that a FIRST report does not append "(was Never reported)" to every newly enrolled host. Reintroducing either defect turns them red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
The card carries what you want at a glance -- name, MAC, image, last deploy, group -- and said nothing about whether the machine is still talking to FOG. That answer was two tabs away even after the General tab started showing it. One note, "Last Check-In", from whichever client last spoke, labeled so the card is not ambiguous about which one: "2026-09-05 06:29:14 (agent)". The agent wins when both columns are set, and deliberately not by comparing dates: a host that has enrolled an agent is a host whose legacy check-in has stopped moving, so the agent is the live signal even on the day it is installed. Falling back to hostLastCheckin keeps the card useful for hosts that have not migrated, which during a migration is most of them. A host neither client has ever reached reads "Never" with no source named, because naming one there would assert a client that was never installed. Two literal dateOrNever() calls rather than one with a computed column name. The column decides whether a date can predate the UTC boundary, and a ternary hides that from both a reader and tests/utc-storage-boundary.test.php -- which caught it. Verified against the three real cases on the lab server: host 105 and 239 (agent set) resolve to the agent's stamp, host 48 (neither) to Never. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
Eleven agent.* audit types are written today -- enroll, token, result, inventory, software, printers, directory, directory.move, secureboot, usersession, wake -- every one tagged subjectType `host` with the host's id. Nothing could read them by host. AuditManagement lists the whole install in one flat grid whose getList() takes no arguments, so "what has this machine's agent been doing" meant scrolling past every other machine. Two surfaces, one query behind both: - A per-host tab on the host page, beside Task/Snapin History where the other per-host ledgers already live. - A page under Logging listing one row per host -- hostname, event count, last activity, last event -- expanding to that host's rows on demand. SUMMARY FIRST rather than a flat grid with group headers. DataTables' rowGroup groups only within the current PAGE, and the audit grid is serverSide, so one hostname would head a dozen separate pages. registerTable() also auto-pages any table using rowGroup -- Scroller cannot reconcile injected header rows -- which fog.audit.list.js already records as why the audit grid does not group. And an agent writes a row per changed fact per host, so a flat list is the one thing that grows without bound while a per-host summary is bounded by the fleet. The grouping happens in SQL, which is also the only way it is exact across the whole table rather than per page. The summary joins auditLog back to itself on MAX(alID) to name the last event type. GROUP_CONCAT(alType ORDER BY alID DESC) with SUBSTRING_INDEX would build a string of every row's type per host to read the first one, and truncates silently at group_concat_max_len. The join to `hosts` is LEFT: rows outlive the host, and on the lab install most of them already have -- those render as "(deleted host N)" rather than an empty cell that reads as a rendering fault. Its own permission node, not an alias onto `audit`. ADR 0021 made audit.view narrow because the audit log discloses attempted usernames and refusals; agent rows are what a machine reported about itself. Aliasing would force anyone who may see what an agent did to also see every failed sign-in in the install. view only -- auditlog has no create, update or delete route (ADR 0021 Decision 8). Both surfaces filter through one TYPE_PREFIX constant rather than listing the type names, so a new fact kind stays what the route rule says it is: a registry entry and a block in the poll, not a third place to remember. Verified against the live lab: the summary returns 21 hosts, and the scoped endpoint returns 79 rows for host 105 and 134 for host 239 -- the same counts the aggregate computed by a different query. 98 audit rows exist for host 105, so the type filter correctly excludes the 19 that are not the agent's. An absent, zero, negative or injection-shaped id returns nothing rather than falling through to every host. Seven mutants killed: the scope guard, the shared constant, a hardcoded type, the node alias, the menu group, rowGroup, and a dropped column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
Two changes to the same strip at the top of the host and group edit pages.
REMOVED: the host card's "Primary Group". It meant
minId($this->obj->get('groups')) -- the lowest-id group the host happened
to be in. There is no primary group. A group GRANTS, and what a host ends
up with is resolved from every group it belongs to at task time, ordered
by groupOrder (ADR 0038, FOG\Assign\Resolver). So the label named a rank
that does not exist and the value picked one membership arbitrarily.
Nothing replaces it. Listing every group instead was considered and
dropped: a host in eight groups blows the card out, which makes the card
worse rather than better, and the Group Associations tab already shows
them properly.
ADDED: Deploy and Capture on the host card, Deploy and Multi-Cast on the
group card. One click, from whichever tab is open, so an admin already
looking at a host does not have to go back to the grid and find the row
again to do the obvious thing to it.
This is not a new pattern. The host LIST has carried the same three
buttons since _quickTaskItems(), for the same reason: these are the task
types that need no options, which is the whole reason they can be one
click. The pairing is the one that method already documents -- Deploy and
Capture are what a single host wants, Deploy and Multi-Cast what a set of
them wants. On a group it is also what the server will accept:
GroupManagement::deployPost() throws "Groups cannot create capture tasks"
outright, so a Capture button there could only produce a toast saying no.
Every button confirms first, and this is the one place it deliberately
differs from the list. There you tick a row to get the buttons; here you
arrive on the page just by clicking a host name, so one stray click would
deploy over a running machine -- or, from a group, over all of them. The
text is built server side because it is translated, and it names the
target: the host, or the group AND its member count, which is the fact
that decides whether you meant to press it and the one thing the button
itself cannot show.
Mechanically:
- FOGPage::$noteActions, a pre-rendered string, echoed by
renderInfoCard() in a right-aligned column. ms-auto, not a float: the
row is display:flex and a float would do nothing there.
- It rides the existing EDIT_INFO_DATA hook alongside notes/noteSources,
so a plugin adding a button does it the same way it already adds a
line. A second event would mean two registrations for one card.
- FOGPageRender::renderQuickTaskActions() builds them, gated on
{node}.task -- the action ?node=X&sub=deploy resolves to through
Authorization::_subToAction(), so the gate here and the gate the POST
hits are the same string by construction.
- The script posts straight to ?node={node}&sub=deploy with
scheduleType=instant, skipping the options form. Nothing on it these
types need, and everything its POST is checked for -- pending host,
assigned and enabled image, protected image on a capture, one image
across a multicast -- is checked in deployPost(), not in the form.
scheduleType is the one field that must be sent: validateScheduleType()
throws on an absent value rather than defaulting.
- Suppressed where the server would refuse anyway: a pending host, an
empty group.
Neutral outline buttons rather than a type color. Nothing here is the
card's commit action -- the General tab's Update is -- and these are
shortcuts in a header strip, not a decision cluster in a form footer. The
weight a red button would carry is carried by the confirmation instead.
tests/info-card-quick-tasks.test.php pins the four things that fail
silently: the permission gate per node (host.task must not unlock the
group card), the confirmation being present AND naming the target, the
values being read through get() rather than as properties, and the pair
each page asks for. Each was verified by reintroducing the defect and
watching it go red.
FOG_BCACHE_VER 360 -> 361 for the changed script.
Co-Authored-By: Claude <noreply@anthropic.com>
Reported against host 105: the summary says 79 events, expanding shows 10, and there is no pager and no scroll to reach the other 69. Self-inflicted. The child table was written as a bare .DataTable(), which opts out of every convention registerTable() applies -- including its `dom`, which is where the pager lives, and its Scroller setup. It went through the helper's defaults for nothing and got DataTables' instead. Building it with registerTable() fixes the report and answers the wider question at the same time: the child now carries the SAME infinite scroll as every other grid in FOG. Collapsed grouping and infinite scroll were never actually in tension -- the tension is between rowGroup and Scroller, and grouping in SQL means there is no rowGroup to have it with. Nothing about registerTable() needed changing to get both. Reproduced and verified in a standalone harness driving the real fog.common.js and DataTables against a stubbed endpoint, because the UI itself is behind an identity provider I cannot sign in to. Before: 10 rows loaded of 79, Scroller off, no scroll body. After: 79 of 79, Scroller on, a 560px viewport over 1742px of content, scrolled to the bottom, last row "event #79". That harness also surfaced an uncaught TypeError from fog.common.js itself. registerTable() sizes a Scroller table on a setTimeout(0) after init, and a grid built into a node still being attached -- a DataTables child row is exactly that -- reaches fogSizeScroller() before its wrapper is in the document. container() is null there, `outer` falls back to it, and getBoundingClientRect() threw out of the timeout. Now guarded, for the same reason and in the same way the function already guards init(): there is nothing to measure against yet, and whatever attaches the node re-runs the sizing pass once it has. The error is gone from the harness console and the sizing is unchanged. Three mutants killed: the bare .DataTable(), and the null-container guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
btn-outline-secondary keeps #6c757d as the TEXT color. Against the dark card (#212529) that is 3.29:1 -- under the 4.5:1 AA floor for body-sized text. Filled puts white on #6c757d instead and holds 4.69:1 in both themes. Measured in a browser against the shipped adminlte4.min.css and fog-default-ui.min.css, rendering the real renderInfoCard() output, not assumed from the Bootstrap defaults. It is also what the host list's own quick buttons already are, since DataTables draws its button bar filled -- so the two places that offer the same one-click tasking now look the same. The test pins the class rather than the ratio: a browser is what measures contrast and the suite is PHP, but the class is the part a future edit would actually change. Co-Authored-By: Claude <noreply@anthropic.com>
Both checks were red on #1707 before this branch existed, and #1712 inherited them. Neither is a code defect -- both are a baseline that the change which moved it did not update. phpstan pass 1: AgentActivityManagement's constructor takes $name and ignores it, overwriting with _('Agent Activity'). That is the shape EVERY page in src/Pages has -- TaskManagement, HostManagement, GroupManagement, ReportManagement, all of them -- and every one of the others is already in phpstan-baseline.neon under constructor.unusedParameter. The new page just had no entry. Added one, in path order beside its neighbours. Changing the constructor instead would make this page the only one in the directory with a different signature, to satisfy a rule the other 27 are exempt from. phpstan pass 2: 26 entries for the agent test files. Worth saying that CI never reported these -- the job runs both passes as separate steps without continue-on-error, so pass 1 exiting 1 meant pass 2 never ran. Fixing pass 1 is what makes them visible, and they would have turned the check red again on the next push looking like a new regression. They are the usual test-file shapes: PHPStan narrows a literal, so an in_array() that pins a constant against its own list, or a ?? guarding an offset it can prove exists, reads as "always true". phpstan-tests.neon's own header says this pass "adapts to the tests as they are written, it does not ask them to change", so they are baselined, not rewritten. Generated with --generate-baseline into a temp file and MERGED, not applied: a straight regeneration emits only the 26 unignored errors and would have dropped the 196 entries already there. Paths rewritten repo-relative -- the generator writes absolute ones, which would match nothing on a CI checkout. Verified as a set: 196 in, 222 out, 0 lost, 26 added, no deletions in the diff. upgrade rehearsal: 3 constraints landed that the fixture did not know about -- fk_agentEnrollment_aeHostID, fk_agentWake_awSenderID and fk_agentWake_awTargetID, all agent tables referencing hosts. So declared 96 -> 99 and present 94 -> 97, while MISSING stays at the same 2 named constraints and the integrity block is untouched. Nothing regressed; the count moved because the schema grew. The test's own docblock asks for exactly this: "a change that legitimately moves the numbers -- adding a constraint -- updates it in the SAME commit and the diff is visible in review." Reproduced locally first against a throwaway mariadb:11.8 container rather than the lab server, got CI's diff byte for byte, confirmed the three new constraints in information_schema, then updated the fixture and re-ran on a second clean database to check it passes from scratch rather than only on the one it was generated from. Co-Authored-By: Claude <noreply@anthropic.com>
Info card: one-click Deploy/Capture, and no more "Primary Group"
The one-click Deploy/Capture/Multi-Cast buttons in the info card asked for confirmation through window.confirm(). It works, and it looks like nothing else in FOG: the browser dialog cannot be styled, ignores the dark theme, and prefixes the page URL, so the one place the app asks before wiping a machine is the one place that reads as though the site got something wrong. renderQuickTaskActions() now emits a modal beside the buttons, the same shape assocDelModal() uses -- which is what every other "are you sure" in this app already looks like. One modal per card, not one per button: the script fills its body from the clicked button's data-confirm, so two buttons cannot drift into two wordings of the same question. The text is still built server side and still translated. A .modal is position:fixed and display:none until shown, so it contributes nothing to the flex row it is emitted into. In fog.common.js the click handler now records which button opened the modal and the request moves to fire(), called from the modal's Create. `pending` is cleared before the request, so a second click during the hide animation has nothing left to commit, and the per-button in-flight lock is unchanged. Both handlers share the click.fogQuickTask namespace so the existing .off() still clears the pair on AJAX nav. The body is set with .text(), never .html() -- data-confirm carries an admin-supplied host or group name. Measured in both themes with the real stylesheets: the buttons stay at 4.69:1, the modal's Cancel at 5.92:1 light / 11.85:1 dark, and its Create (the modal-warning fill) at 5.14:1. All pass WCAG AA. FOG_BCACHE_VER 361 -> 362, since fog.common.js changed. tests/info-card-quick-tasks.test.php grows five checks covering the modal: that it is emitted, that it is the only one, that the footer is a dismiss plus a commit, that the body is a filled-in placeholder rather than static text, and that no window.confirm() is left in the handler. Each was proven by reintroducing the defect and watching it go red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JWJMQYE2br8E7Ehr55SJp2
Confirm a quick task in a modal, not window.confirm()
The expand on this page never worked. Clicking a host's chevron rendered DataTables Responsive's hidden-column list; the nested table it was meant to open was never constructed. Measured on a live install at 1920px with zero columns hidden, so it was not a narrow-viewport artifact. A DataTables row has ONE child slot. registerTable() turns Responsive on for every grid and Responsive claims that slot, so row.child() handed it a table it then overwrote. Nothing threw -- which is why 7b207c2 could fix "the expanded host was stuck at ten rows" by repairing a pager inside a table that did not exist. So there is no child row now. The page is one grid, grouped by host with rowGroup: the header carries the host, its event count and the expand control, and expanding adds that host's events to the same grid as ordinary rows. No table nested in a table, no second scrollbar, no second pager. Three things had to be true for that to hold, and each was found by measuring rather than by reasoning: - A group whose rows are all filtered out renders NO header, so collapsing by filtering would make every collapsed host vanish. Each host's newest event is therefore seeded into the table and never filtered. It anchors the header and doubles as the thing worth seeing when all is collapsed: what each agent last did. - rowGroup starts a new group every time its dataSrc changes down the ORDERED rows. Ordering by time alone let one host's older events fall past the next host's newest and drew its header twice, so a hidden column sorts every row of a host on that host's last-activity time plus its id -- groups stay whole and stay ordered by recency rather than by name. - listem()'s recordsTotal is every row in auditLog, not the host's. The cap notice now reads recordsFiltered; against recordsTotal it told a 134-event host it was truncated at 500. The flat event set stays unbounded (FOG_AUDIT_RETENTION_DAYS defaults to 0, keep forever), which is why the seed is a summary query and each expansion is capped, and why rowGroup over a serverSide grid was never an option. Separately, and not specific to this page: a grid that says `select: false` no longer gets Select All and Deselect All, and a page that says it is not selectable no longer gets "Delete selected". That was decided by a hardcoded list of node names, which this page was never added to -- so it shipped a red Delete selected over a table with no delete route anywhere in FOG (ADR 0021 Decision 8). FOGPage::$selectable replaces the list, and registerTable() drops the two buttons, so both halves are stated where they are enforced. The 33 tables already passing `select: false` stop showing two enabled buttons that did nothing. tests/agent-activity-grouping.test.php covers all of it, and each of its eight gates was proven by reintroducing the defect and watching it go red. It strips comment lines before scanning, because the first run failed on row.child() and on the old node list where both appear only inside the comments saying never to go back to them. tests/agent-activity-page.test.php had pinned the arrangement this replaces -- "the grid does not use rowGroup" and the child table going through registerTable. Those three checks now assert the opposite, and one of them that no child table comes back by any route. FOG_BCACHE_VER 362 -> 363. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JWJMQYE2br8E7Ehr55SJp2
Agent Activity: group by host in the grid, and expand in it
Three faults, one root. Paging counts ROWS; this page's unit is HOSTS. Expanding one host with 83 events at 25 rows a page filled pages one to three with that host and pushed every other host onto page four. rowGroup redraws a group's header on every page the group spans, so the same host then appeared four times, each apparently expanded -- which is what it looked like to the person reading it, and it is not a rowGroup bug. It is what paging by row does when the thing grouped is larger than a page. No page length fixes that, because the number of rows an expansion adds is a property of the host and not of the setting. So paging is off. Collapsed, the grid is one row per host; expanded, it gets longer and you scroll. The seed is still bounded by MAX_HOSTS and each expansion by ROWS_PER_HOST, so this is not "no limit". Scroller is not the alternative -- registerTable() excludes any rowGroup table from it. With paging gone the "entries per page" control has nothing to put in itself, and rendered as an empty box beside its own label. Reported as unreadable in both themes, which it was: there was nothing in it to read. lengthChange: false removes the control rather than styling an empty one. And the toolbar's Refresh is dt.clear().draw() + ajax.reload() -- it throws the rows away and re-fetches the seed. The per-host maps survived that, so a host still marked `loaded` was never re-fetched, its rows were gone, and clicking its header did nothing at all. It read as the expander breaking permanently after one press of Refresh. They now reset on xhr.dt, which fires when the new seed lands. Verified on the live install: 21 group headers collapsed and 21 expanded with no duplicates, no host displaced, the length control and pager both absent, and expand working again after a Refresh. Three more gates in tests/agent-activity-grouping.test.php, each proven by reintroducing the defect and watching it go red. FOG_BCACHE_VER 363 -> 364. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JWJMQYE2br8E7Ehr55SJp2
Agent Activity: stop paging a grid whose unit is hosts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Server side of fog-agent, the in-progress replacement for the FOG client. This PR stays in draft and collects every fogproject commit for the agent work until the agent is ready; nothing here is reachable from the existing client.
What is in it so far
Enrollment (
POST /agent/v1/enroll, unauthenticated)agentEnrollment/agentEnrollTokentables and fourhostAgent*columns (schema step 416).FOG\Agent\Enrollmentmatches SMBIOS identity and CSR key against existing hosts, pends unknown or rebinding machines for an admin, auto-approves via a minted token or an active deploy task.fog-sign-node-cert agent: clientAuth-only leaf signed by a new agent intermediate CA. The CN carries the host id, never a name.GET /agent/enrollments,POST /agent/enrollment/{id}/{action}.Authenticated channel (client certificate)
FOG\Agent\Principalre-verifies the presented certificate in PHP against the agent CA bundle and binds it to a host by SPKI fingerprint.Routegates every/agent/v1/*path except enroll on that principal;POST /agent/v1/pollrecords version and check-in.Fixes found on the way
Host::addMAC()beforesave()wrotehostMACrows with an emptyhostID, inBoot\Registrationtoo.BootFileManager->find()(1.5 API, swallowed by a catch) inFOGPage::_bootFileRow.getClass('Name')so 1.5-era method names on managers are findings. The 16 pre-existing findings it surfaced are baselined for a separate cleanup.Verified
tests/agent-principal.test.php(new) plus the existing suites: 252/252. phpstan clean.Still to come under this PR
Pending Agents UI page, token minting UI, capabilities in the poll answer, certificate renewal route.
🤖 Generated with Claude Code
https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh