manage consists of two halves: the server in this repository, and the
client package that gets copied into each served project.
One server installation serves one product with a manageable number of
instances. For another product, manage is deployed again.
Relevant directories:
admin/ – UIapi/v1/ – client interfaceincludes/ – shared librarystorage/ – all state, not reachable over the webclient-package/ – the redistributable folder for projectsProject (instance) Manage server
─────────────────── ─────────────
manage-client/
bin/manage-client.php ──── check ───► api/v1/manifest.php ──► storage/releases/manifest.json
ui/panel.php ──── update ──► api/v1/package.php ──► storage/releases/packages/
lib/*.php ──── backup ──► api/v1/backup.php ──► storage/backups/<instance>/
──── status ──► api/v1/heartbeat.php ──► storage/instances.json
│
▼
admin/ (password login)
The client pulls; the server never pushes. There is no connection from the server to the instance, which makes running it behind NAT and firewalls straightforward.
Two separate paths:
| Path | Who | Means |
|---|---|---|
admin/ |
humans | a password, session, CSRF, rate limiting |
api/v1/ |
instances | instance id + token in two headers, stateless |
Tokens are stored as a SHA-256 hash and compared in constant time. The plaintext token appears exactly once, when created or rotated.
Details: client-package/docs/08_PROTOCOL.md.
storage/instances.json is the link between both modules. It replaces two
separate mechanisms from the predecessor solution: the backup server's name
list, and the update server's completely missing client identity.
{
"instances": [
{
"id": "example-prod",
"label": "City of Freising Production",
"enabled": true,
"token_hash": "…",
"created_at": "…",
"token_rotated_at": "…",
"last_seen_at": "…",
"last_ip": "…",
"version": "v1.3.14",
"php_version": "8.3.6",
"disk_free": 12884901888,
"pending_migrations": 0,
"last_backup_at": "…",
"backup_count": 3,
"notes": ""
}
]
}
Every successfully authenticated request updates last_seen_at and
last_ip. The overview stays current this way, even without its own
heartbeat.
A deleted instance can no longer upload anything; its already-stored backups remain and stay visible in the UI.
storage/releases/manifest.json is the release database; the packages sit
next to it in packages/:
{
"latest": "v1.3.0",
"releases": {
"v1.3.0": {
"version": "v1.3.0",
"package": "packages/example-orderform-v1.3.0.zip",
"sha256": "…",
"size": 2199,
"published_at": "…"
}
}
}
Checksum and size are always computed by the server after upload; they are
never taken from the uploader. An upload automatically sets the release as
latest.
The download URL is built from MANAGE_PUBLIC_URL, not from the Host
header. The predecessor solution derived it from HTTP_HOST — a value
controlled by the client.
storage/backups/
index.json metadata of all backups
<instance>/backup-YYYYmmdd-HHMMSS[-N].zip
After storing a file the server re-checks the checksum and deletes it on mismatch. An existing filename is never overwritten.
With the S3 archive active, the local disk works as a fast staging area and the bucket as the complete archive:
s3_retention backups per instance (default 365).retention backups (default 30), but
never deletes a file while its S3 upload is still pending.If S3 is unreachable, the local copies grow past their retention instead of losing the only copy. Failed S3 uploads are retried on the next upload from the same instance, or via the button in the UI.
S3 errors never fail a client upload: the local copy already exists. They
are logged to storage/logs/s3.log.
Flat files only, no database server. Every write goes through
manageWriteJsonFile(): first into a .tmp file, then rename(). That way
an aborted request can never leave a half-written index behind.
Known limit: concurrent uploads from the same instance can collide while
writing index.json. With a handful of instances doing nightly backups this
is practically excluded; with many concurrent uploads a lock would be
needed.
| File | Content |
|---|---|
storage/logs/access.log |
JSONL: logins, releases, received backups, downloads |
storage/logs/error.log |
JSONL: failed logins, rejected uploads, internal errors |
storage/logs/s3.log |
plain text: S3 diagnostics with status, redirects and request id |
The JSONL logs rotate past MANAGE_LOG_MAX_BYTES and are removed after
MANAGE_LOG_MAX_AGE_SECONDS.