For an LB MAIN cannot reach over SSH. `cluster:enrol-code <id>` issues a
one-time code (MAIN URL, panel key hash, 128-bit secret; 30 min). MAIN
keeps only K_req/K_res, sealed to the machine under a row MAC.
The node sends enrol_code (sid:<n>, epoch 0; MAC'd with K_req, signed by
the key it asks to enrol, body SEALed to the panel box key). Nothing is
minted until `cluster:enrol-approve <id> <SAS>`: the admin types the SAS
the node shows, which binds its keys; five wrong entries reject. The
node then collects epoch 1 via enrol_code_status (MAC'd with K_res,
panel-signed `pre`) and finishes with enrol_complete.
A wrong code MAC records nothing; other keys under a used code are a
signed ENROL_CONFLICT, the same keys again are accepted. Migration 036
keeps the request's per-epoch key until approval. Both commands are
MAIN-only (stripped from the LB archive).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
An active node with intact keys gets a new epoch without SSH:
GET challenge?cn=, then a node-signed POST token_rekey (epoch 0, no MAC)
whose body is SEALed to the panel box key under the request context.
MAIN checks the signature before recording anything, then the nonce,
state=active, a once-a-minute limit, the single-use challenge
(NonceStore::consume, atomic via a used marker), the instance_id
attestation (a mismatch quarantines) and the licence (the mint). The
reply is panel-signed `pre` and bound to the node and request nonce.
TokenService::rekey drops every other epoch row.
The install data now carries panel_box_pub; the test fake gets a real
box key, openSealed and licence-gated granting signatures.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcNdxHieZuUh3EnMUKt7zf
provisionCluster pushes the agent, runs keygen on the node (keys never
leave it), has the node verify MAIN's signed health before any token is
minted, then installs epoch 1 and starts the agent, which completes with
enrol_complete. The API off, no extension or no agent binary leaves the
node legacy; only an extension refusal or an unreachable API fails the
install. A keepalive in service boot and the root cron runs the agent on
enrolled nodes, and stops for good when MAIN has stopped the node.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
ClusterApi serves health, challenge, enrol_complete, token_refresh, hello
and heartbeat. Nothing is written before the MAC (and, for token ops, the
node signature) verifies; refusals are panel-signed and bound to the
request nonce. Token refresh is idempotent per agent ephemeral key, so a
node holds at most two valid epochs. ClusterApiTest runs every flow against
a fake of the extension's token half and, opt-in, a real xcvm_core.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
src/Core/Cluster/Crypto implements the wire formats of the extension's
cluster API v1 (xcvm_core ADR-002), in plain PHP with sodium and OpenSSL:
- Enc: the lp()/u64 canonical encoding.
- Box: XCVM-BOX-v1 session bodies.
- Seal: XCVM-SEAL-v1 to an X25519 key; refuses the all-zero shared secret
and binds purpose and context.
- PanelSig: verifies panel signatures against the closed tag registry.
- NodeSig: the node key's own signatures, under a separate domain string
so they can never pass as panel records.
- Canonical: the panel-owned request/response serialisation. It is the
BOX context and the MAC input, with a ±90 s window and header parsing.
- Ticket, RelayAuth, FileDigest: relay and file tickets, the child's
per-connect relay proof, and owner-signed file digests.
- ClusterCrypto: a facade over XC_VM::cluster_*. Refusals throw
ClusterRefusedException with the extension's reason code.
- ClusterCryptoFactory: fails closed (ClusterUnavailableException) without
the extension, outside API 1–1, or without sodium/AES-GCM. No PHP
fallback ships.
Tests:
- The panel matches the extension's shared vectors byte for byte
(tests/Support/cluster_vectors.json, copied from xcvm_core): SEAL, BOX,
panel signatures, the licence binding and CK_B, and the whole token
chain an agent runs.
- tests/Support/cluster_canonical_vectors.json freezes the canonical
request/response bytes for the Go agent.
- Also added: SignTagSeparationTest, ClusterTicketsTest,
ClusterCryptoFailClosedTest (including a check that nothing under
Core/Cluster signs as the panel or computes tokens) and
ClusterCryptoBenchTest.
- Timing: 64 KB is ~0.25 ms p99 against the 1 ms budget; 8 MB is ~41 ms
here against a 40 ms target, guarded at 2x.
The reference crypto that only xcvm_core or the Go agent computes
(binding, token MAC, session keys, panel signing with a seed) lives in
tests/Support and never ships.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
Rollback previously swapped in older code but never touched schema
changes newer migrations applied — the rollback's own comment admitted
"migrations are forward-only, so a downgrade cannot undo them". Of the
25 core migrations, two actually DROP a column older code still reads
(013_drop_asn_allocated, 016_per_repo_update_channel); rolling back
past either without restoring it would crash the restored old code.
Add MigrationRunner::rollback(): diffs the currently-applied migration
set (the `migrations` tracking table) against the TARGET version's own
migrations/database/up/ file list — read straight out of the
already-downloaded rollback tarball via the new
ReleaseArchiveInspector (PharData/tar, no extra network call, no full
extraction) — and reverses, in descending order, every migration the
target version doesn't carry. Only migrations with a down.sql
counterpart are reversed; migrations lacking one are left applied and
logged as skipped, which is safe by design since every migration
without a down file only ever ADDs schema or business data that older
code simply never references.
Restructured src/migrations/ into database/up/ (the 25 existing
forward migrations, unchanged content) and database/down/ (18 new
reverse migrations: the two critical drops plus every mechanically-safe
ADD-column/cron-row inverse; excluded are three migrations touching
real business data — activation_codes, category_templates, lines'
custom_data — and four data UPDATEs with unrecoverable prior values).
Kept as two directories rather than a `.down.sql`-suffixed sibling of
the up file: glob('*.sql') in run() would otherwise pick up the down
file too (it still ends in .sql) and try to run it forward.
Wired into UpdateCommand's rollback case right after the tarball
download+MD5 verification (MAIN only, aborts the rollback on failure —
the pre-rollback DB backup remains the recovery path either way).
Also extends the shared TestDb harness (tests/Support/TestDb.php) to
strip MySQL-only ENGINE=/CHARSET=/COLLATE= table-option clauses under
its default SQLite backend, so MigrationRunner's real production DDL
can be exercised in tests without a live MariaDB.
TestDb can now run the DB-touching unit tests against a real MariaDB as
well as the default in-memory SQLite, so the suite can be exercised on
the panel host (which ships pdo_mysql, not pdo_sqlite). When
XCVM_TEST_DB_DSN is set it connects there and translates the SQLite test
DDL on the fly: AUTOINCREMENT -> AUTO_INCREMENT, a bare INTEGER PRIMARY
KEY gains AUTO_INCREMENT, and each CREATE TABLE is preceded by DROP TABLE
IF EXISTS (a MariaDB schema persists across the per-test connections that
:memory: starts fresh). DDL is routed through exec() on both backends so
the ModuleMigrator path (which runs DDL via query()) works too, and the
MySQL session uses a permissive sql_mode to match SQLite's leniency.
AuthRepositoryTest back-quotes the reserved column `key`.
Three env-fragile guards are tagged #[Group('skip-on-panel')] so the
deployed-panel run can exclude them (--exclude-group skip-on-panel):
ArchitectureTest and StreamTokenCallSitesTest scan the repo src/ tree
(absent / polluted in a flat deploy; they also self-skip when it is
missing), and LoginSessionFixationTest runs in isolated child processes
that the panel's ionCube/OPcache PHP cannot reconstitute.
Verified green on all three backends: SQLite (local, 721), MariaDB 11.4
(container, 721), and the panel's bundled PHP 8.1 + server MariaDB
(713, with the group excluded).
Two regressions surfaced when running the suite (9 + 3 errors):
- setDb(DatabaseHandler) rejects the SQLite double: the DatabaseAware
refactor gave setDb() a strict DatabaseHandler hint, but TestDb was a
standalone class. Make TestDb extend DatabaseHandler (a real subtype) so
it satisfies the seam; its own constructor wires sqlite::memory: and never
calls the MySQL-connecting parent constructor. Overridden methods widen
parameter types (contravariant) and add return types, so LSP holds.
- validateHMAC(int|string $rExpiry) rejects null: HmacTokenTest passed null
for an intentionally-empty expiry. Pass '' — identical HMAC input, and the
strict type stays correct (the sole production caller always passes a value).
Suite is green again: 580 tests, 0 errors.
Move Core/Database into XcVm\Core\Database (DatabaseHandler, Database,
MigrationRunner, QueryHelper). First of the Core hub sub-layers.
- Namespace the 4 classes; DatabaseHandler extends Database (same namespace);
built-ins/ioncube qualified (\PDO, \PDOException, \Exception, \Throwable,
\XC_VM); Database keeps its 'use XcVm\Core\Logging\FileLogger;'.
- Add 'use XcVm\Core\Database\...;' to referencing files (DatabaseHandler 51,
Database 64, QueryHelper 29, MigrationRunner 3) + 2 test files (PHPStan does not
analyse tests/, so PHPUnit is the gate there).
- Rewrite pre-existing leading-backslash global refs (\DatabaseHandler etc., e.g.
in @param docblocks of ResellerApiDispatcher/ResellerTableRenderer) to the full
FQCN \XcVm\Core\Database\... — a 'use' import does not cover a leading-\
reference. Done with a lookbehind so FQCN continuations and use-lines are intact.
- phpstan-baseline.neon regenerated (292→292; pre-existing Database/migration_logic
findings re-anchored after class names in messages gained the namespace).
Verified: php -l clean; PHPStan no errors; PHPUnit 295/295.
Adds tests/Support/TestDb.php — a Database-compatible wrapper over
sqlite::memory: injected via setDb() — and StreamConfigRepositoryTest as a
worked example (SELECT/list/keyed/DELETE-cascade/not-found). 286 -> 292 tests.