move_detect/server/docs/server-deploy.md
유창욱 9ed759befc docs(deploy): fix MySQL/runbook/secret guidance; offline camera probe
- server-deploy.md: state MySQL 8.0.13+ / MariaDB 10.2+ is required (expression
  defaults), not "5.7+" — an import against 5.7 is a day-1 ERROR 1064 blocker.
- pilot-runbook: add the air-gapped Pi path and mark pip/fetch as build-machine
  only; stop the shipped doc from telling a Pi operator the bundle "does not exist".
- install_offline.sh: instruct the camera probe before enable (mirrors setup_online).
- setup_online.sh: chmod 600 device.env on read; README documents delete + rotate.
- systemd unit: order After/Wants=time-sync.target so occurred_at is real wall time.
2026-07-25 19:21:01 +09:00

281 lines
15 KiB
Markdown

# Cafe24 Server Deploy Guide (P1-B)
This document covers deploying the `server/` PHP application (ingest API + dashboard) to a
Cafe24 shared/managed PHP hosting plan. The application has **zero runtime Composer
dependencies** (PHPUnit is dev-only) and speaks to MySQL exclusively through `PDO` with
prepared statements — there is nothing to `composer install` on the production host.
## 1. Directory layout on the server
Only `server/public/` may be reachable from the web. Everything else must sit **outside**
the web root (one level up, or wherever your Cafe24 plan's "web root" / "html" folder is),
or be blocked by hosting-level access rules if your plan cannot place files outside the
web root at all.
```
/home/<account>/ <- NOT web-reachable
├── src/ <- App\ classes (App\Db, App\Repository\Pdo, ...)
├── vendor/ <- Composer's generated autoloader ONLY (see below)
├── storage/
│ └── snapshots/ <- re-encoded JPEGs; must be writable by the PHP process
├── config.php <- copied from config.example.php, secrets filled in (see §3)
├── schema.sql <- only needed transiently for the phpMyAdmin import (§4);
│ delete it from the server afterwards
└── public/ <- web root. On Cafe24 it is named `www`; when that `www`
├── index.php already serves another site the app is mounted in a
├── .htaccess subdirectory (`www/move_detect/`) and the entries above
└── assets/ sit at the FTP root — see below.
```
`server/public/index.php` finds `config.php`, `src/`, and `vendor/` by **walking up** from
itself until it sees both `config.php` and `vendor/autoload.php` (max 4 levels). It does not
hardcode `/..`, so the same tree works whether the app owns the web root or is mounted in a
subdirectory of one — in the latter case the app root is two levels up, not one, because
nothing secret may sit under a docroot the other site also serves.
The URL prefix comes from `SCRIPT_NAME` (`App\Http\Request::baseFrom()`), which the web
server sets — never from client input. `Request::pathFor()` strips it before routing, and
views/JS prefix every link with it (`view_url()` / `window.APP_BASE`). Without that, routes
declared as `/login` would 404 under `https://host/move_detect/login` and every asset would
resolve against the co-hosted site.
`server/deploy/build_cafe24_package.sh` emits `www/move_detect/` by default; set `APP_DIR=`
(empty) to mount the app directly in `www/`.
**`vendor/` must ship — but only the autoloader.** `public/index.php:3` is an unconditional
`require __DIR__ . '/../vendor/autoload.php'`, so a transfer without `vendor/` fatals on
every request, including §10's own smoke check. There are no runtime Composer *packages*
(`composer.lock`'s `packages` is empty), but the app does need Composer's generated
autoloader to map `App\` → `src/`. Generate the no-dev one on the build machine:
```bash
cd server && composer dump-autoload --no-dev --classmap-authoritative --no-interaction
```
That emits ~8 files (~30 KB): `vendor/autoload.php` plus `vendor/composer/*.php`. Copy
exactly those. Do **not** upload the working tree's `vendor/` as-is — it carries PHPUnit
and a dev `autoload_files.php` that eagerly `require`s
`tests/Support/InMemoryRepositories.php` on every request, a file this deployment
deliberately excludes.
`--classmap-authoritative` is not optional: `src/Auth/Session.php` declares three symbols
(`SessionStore`, `ArraySessionStore`, `PhpSessionStore`) under a filename matching none of
them — which is why `composer.json` carries an explicit `classmap` entry for it. Plain
PSR-4 would look for `src/Auth/PhpSessionStore.php`, miss, and fatal at `index.php:19`.
`server/deploy/build_cafe24_package.sh` does all of this, verifies it, and lays out the
tree below automatically — prefer it over assembling the transfer by hand.
If your Cafe24 plan cannot place folders outside the web root, **stop — this layout is
unsupported, and the obvious workaround does not work.** `public/index.php` resolves
`../vendor/autoload.php` (line 3), `../config.php` (line 17), and `../src/View/` (line 43)
relative to itself, so moving those *into* `public/` puts them exactly where those three
`require`s do not look: every request fatals. Making it work needs source changes to
`index.php`'s path resolution plus deny rules for `src/`, `vendor/`, `storage/` **and**
`config.php` — not deny rules alone. Raise it before deploying rather than improvising:
`config.php` holds the DB password, and a half-applied version of this is how it ends up
web-readable.
## 2. Host prerequisites — verify before importing data
Check these on the actual Cafe24 PHP build (a `phpinfo()` page or `php -m` / `php -v` over
SSH if available) — do not assume, since PHP builds vary by hosting plan:
- [ ] **PHP version** matches `server/composer.json`'s `"php": ">=8.2"` (the app is written
and tested against PHP 8.5; anything ≥ 8.2 should run it, but confirm the exact
version Cafe24 provisions for your plan).
- [ ] **`pdo_mysql`** extension is enabled (`PDO::getAvailableDrivers()` must include
`mysql`).
- [ ] **`gd`** extension is enabled — `App\Ingest\ImageProcessor::reencode()` calls
`imagecreatefromstring()` / `imagejpeg()` to re-encode uploaded snapshots; without
`gd`, every event with a snapshot silently drops that snapshot (`reencode()` returns
`['ok' => false]` and the caller just `continue`s).
- [ ] **`openssl`** extension is enabled — required for `random_bytes()`'s CSPRNG backing
and TLS to the DB/clients on most builds; effectively always present, but confirm.
- [ ] **`PASSWORD_ARGON2ID` support** (the `argon2` build flag on PHP's `password_hash()`).
Run:
```php
<?php var_dump(defined('PASSWORD_ARGON2ID'), in_array('argon2i', password_algos()));
```
**This is the one that most commonly bites on shared hosting** — some PHP builds
ship without libargon2, in which case `PASSWORD_ARGON2ID` is undefined and both
`App\Security\Password::hash()` (account creation) and
`App\Security\Password::verify()` (login, called on *every* login attempt including
unknown-username ones, per `AuthController::DUMMY_HASH`) fatal immediately — **the
dashboard login is completely broken** until this is fixed. If your plan lacks it,
escalate with Cafe24 support to switch to a PHP build with Argon2, or migrate the
hashing scheme (out of scope for this doc — do not silently downgrade to a weaker
algorithm).
- [ ] **HTTPS is available** for the domain/subdomain you're deploying to (Cafe24 free or
custom TLS certificate) — see §7.
## 3. `config.php`
Copy the example and fill in real values — **never commit `config.php`** (already covered
by `server/.gitignore`):
```bash
cp config.example.php config.php
```
Edit `config.php` (outside the web root, per §1):
```php
<?php
return [
'db_dsn' => 'mysql:host=localhost;dbname=<your_db_name>;charset=utf8mb4',
'db_user' => '<cafe24_mysql_user>',
'db_pass' => '<cafe24_mysql_password>',
'snapshot_dir' => __DIR__ . '/storage/snapshots',
'nonce_ttl' => 86400,
'timestamp_window' => 300,
'rate_limit_per_min' => 60,
];
```
Get the DB host/user/password/database name from the Cafe24 hosting control panel's MySQL
section. Do not reuse the DB password anywhere else, and do not paste it into chat, tickets,
or version control.
## 4. Import the schema
`server/schema.sql` contains the full MySQL 8.0+ DDL (`users`, `devices`, `cameras`, `zones`,
`events`, `event_snapshots`, `thresholds`, `nonces`, `rate_limits`, `audit_log`).
1. Open **phpMyAdmin** from the Cafe24 control panel, select the database created for this
app.
2. **Import** tab → choose file → upload `schema.sql` → Go.
3. Confirm all 10 tables were created (phpMyAdmin's table list on the left, or
`SHOW TABLES;` in the SQL tab).
4. Delete `schema.sql` from the server once the import succeeds — it doesn't need to live
on the host afterwards, and leaving DDL lying around outside the web root is unnecessary
attack surface for no benefit.
**Minimum version — MySQL 8.0.13+ (or MariaDB 10.2+).** Several tables use an expression
default `... DEFAULT (UNIX_TIMESTAMP())` (e.g. `move_nonces`, `move_audit_log`), which is a
hard syntax requirement: MySQL added function/expression defaults in **8.0.13**, and on an
older server the very first such `CREATE TABLE` fails the import with `ERROR 1064` — a day-1
blocker, not a silent degradation. **Check the version in the Cafe24 hosting control panel
(or `SELECT VERSION();`) before importing.** If the account only offers MySQL 5.7, either
request an 8.0 database or replace each `DEFAULT (UNIX_TIMESTAMP())` with an application-supplied
timestamp (the code already passes one on every insert).
Separately, if your MySQL version predates 8.0.16, the `CHECK (...)` constraints in the DDL
(on `role`, `event_type`, `severity`, `level`) are parsed but silently not enforced — harmless,
since the application already whitelists these values before every insert
(`App\Ingest\EventValidator`, `App\Auth\Rbac`, `App\Dashboard\ZoneConfigController::LEVELS`).
## 5. Storage directory
Create the snapshot directory and make it writable by the PHP process (the account PHP-FPM
or mod_php runs as on your Cafe24 plan — typically your hosting account's own user, so a
plain `chmod` from your account is usually sufficient; confirm via a test upload):
```bash
mkdir -p storage/snapshots
chmod 750 storage/snapshots
```
`storage/snapshots/` must sit **outside** the web root (§1) — snapshots are only ever
served through the authenticated `GET /snap/{uuid}` route (`App\Dashboard\SnapshotProxy`),
never as static files. If `storage/` were reachable directly under `public/`, every
snapshot would be an unauthenticated static download by UUID.
## 6. Per-device secrets
There is no self-registration flow for edge devices in P1-B — an admin provisions each
`devices` row (and its HMAC secret) directly, before that device is allowed to sync
config/heartbeat or ingest events (`App\Repository\Pdo\PdoDeviceRepository::secretFor()`
is the single gate all three ingest-side HMAC checks depend on).
Generate a secret and insert the device row via phpMyAdmin's SQL tab (or CLI, if available):
```bash
php -r "echo bin2hex(random_bytes(32)), \"\n\";"
```
```sql
INSERT INTO move_devices (device_id, name, secret, config_version)
VALUES ('pi-01', 'Line 3 — Fall Cam', '<paste the generated secret here>', 1);
```
Deliver the secret to the device provisioning process out-of-band (not email/chat) and
configure it as the device's `HMAC` signing key for `X-Signature` on
`/api/events`, `/api/heartbeat`, and `/api/config`. Rotate a compromised secret by
`UPDATE move_devices SET secret = ? WHERE device_id = ?` with a freshly generated value — the
old value stops working immediately since `secretFor()` always reads the current row.
## 7. First admin account
`App\Repository\UserRepository` has no `create()` method in P1-B (accounts aren't created
through the app UI) — create the first admin directly via phpMyAdmin, using a CLI snippet
to generate the Argon2id hash so the plaintext password never touches the SQL you paste
into phpMyAdmin's history:
```bash
php -r "echo password_hash('CHANGE-ME-to-a-real-passphrase', PASSWORD_ARGON2ID), \"\n\";"
```
(If this errors with an undefined-constant fatal, your PHP build lacks Argon2 — go back to
§2 before continuing; do not fall back to `PASSWORD_DEFAULT`/bcrypt without updating
`App\Security\Password::hash()`/`verify()` to match, since they're hardcoded to
`PASSWORD_ARGON2ID`.)
```sql
INSERT INTO move_users (username, password_hash, role, site_scope, failed_attempts, locked_until)
VALUES ('admin', '<paste the argon2id hash here>', 'admin', NULL, 0, 0);
```
Log in at `/login`, then change the password by re-running the CLI snippet with a new
passphrase and updating the row (there's no self-service "change password" flow in P1-B).
## 8. HTTPS / HSTS
The dashboard sets `cookie_secure => true` on its session cookie
(`App\Auth\Session::PhpSessionStore`) — **the session cookie will not be sent by the
browser over plain HTTP at all**, so the dashboard is non-functional without HTTPS; this
isn't optional hardening, it's a hard requirement for login to work.
- [ ] Enable Cafe24's free TLS certificate (or upload your own) for the domain.
- [ ] Force HTTP → HTTPS redirect (Cafe24 control panel setting, or add a rewrite rule
in `public/.htaccess` above the existing rewrite-to-index rule).
- [ ] Add HSTS via `public/.htaccess`:
```apache
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
```
(requires `mod_headers`; most Cafe24 Apache plans have it enabled by default —
verify the header actually appears on a response before relying on it).
- [ ] Set `session.cookie_secure = 1` at the PHP level too if your Cafe24 plan lets you
set `php.ini`/`.user.ini` overrides, as defense-in-depth alongside the explicit
`cookie_secure` option already passed in code.
## 9. Error display (do not leak stack traces)
Set in `php.ini` or a `.user.ini` in `public/` (whichever your Cafe24 plan supports):
```ini
display_errors = Off
log_errors = On
error_log = /home/<account>/logs/php_errors.log ; outside the web root
```
Several `Pdo*Repository` methods intentionally `throw` on invariant violations that
should never happen in normal operation (e.g. `PdoZoneRepository::save()` throws
`RuntimeException` if a caller supplies a `zone_id` that doesn't belong to the given
`device_id`/`camera_id`, to prevent one device's zones being silently overwritten by
another's). With `display_errors` off, an uncaught exception like that surfaces to the
client as a bare 500 with no body/trace, which is the intended behavior — turning
`display_errors` on in production would leak file paths and internal structure instead.
## 10. Post-deploy smoke check
- [ ] `GET /login` renders over HTTPS.
- [ ] Log in with the admin account from §7; dashboard (`/`) loads and polls
`/api/events`.
- [ ] `POST /api/heartbeat` from a real (or curl-simulated, correctly-signed) device using
the secret from §6 returns `200 {"ok":true}`.
- [ ] A snapshot uploaded via `/api/events` is retrievable at `/snap/{uuid}` only while
logged in, and `404`s for a made-up UUID.
- [ ] `storage/snapshots/`, `src/`, `config.php`, and `schema.sql` (if not yet deleted per
§4) are **not** reachable over HTTP — e.g. `curl -I https://<domain>/../config.php`
and `curl -I https://<domain>/storage/snapshots/` should not return the file/listing.