ARCHITECTURE.md 5.7 KB

Architecture

Overview

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/ – UI
  • api/v1/ – client interface
  • includes/ – shared library
  • storage/ – all state, not reachable over the web
  • client-package/ – the redistributable folder for projects

Data flow

Project (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.

Authentication

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.

Instance registry

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.

Releases

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.

Backups

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.

Two retention tiers

With the S3 archive active, the local disk works as a fast staging area and the bucket as the complete archive:

  • S3: keeps the most recent s3_retention backups per instance (default 365).
  • Local: keeps the most recent 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.

Storage

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.

Logs

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.

Next