0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザインストーラを部品化して1日で載せた|fail-closed・再設置ガード・自己削除

0
Posted at

はじめに

エンジニア以外の人に、自分の共有レンタルサーバーへ OSS をブラウザだけで設置してもらいたい方へ。

自己ホスト型の営業パイプライン管理 OSS NeNe Deal を、共有レンタルサーバーに設置する話です。

設置は最後の 1 点で止まっていました。コンテナも SPA の同一オリジン配信も HETEML 向けの配布アーティファクトも整っているのに、.env の DB パスワードだけは SSH でファイルを開いて手で書く運用になっていて、そこが設置する人の手番として残っていました。エンジニア以外に「自分のサーバーに置いてもらう」なら、ここをブラウザで完結させたい。

やることは「ブラウザインストーラを載せる」で、これ自体は新しくも難しくもありません。ただ今回は、兄弟製品(請求書 OSS の NeNe Invoice など)で同じものを何度か作っていたので、設置まわりの部品はすでに共有ライブラリに切り出してありました。だから deal 側に新しく書いたのは「deal 固有の事情」だけで、要件チェックから DB 接続テスト、.env 書き込み、マイグレーション、管理者作成、自己削除までを 1 日で載せられました。

git のタイムスタンプもそうなっていて、本体の実装コミットとデザイン適用まで全部が同じ日(の夕方から深夜)に収まっています。この記事は「なぜ 1 日で済んだのか」を、部品の境界の引き方から書いた記録です。

この記事で分かること:

  • インストーラの「危ないところ(秘密書き込み・再設置・マイグレーション)」を共有部品に固め、製品固有の事情だけを書く境界の引き方
  • .env を 0640 fail-closed・原子書き込み・値 escape で書き、権限を絞れなければ止める実装
  • 再設置をマーカー+DB プローブの二層で拒み、既知資格情報の dev-seed を消し、完了時に自己削除する設置窓の閉じ方

何を借りて、何を書いたか

インストーラは「どの製品でもだいたい同じことをする」処理と、「その製品だけの事情」が混ざっています。前者を共有ライブラリ(NENE2Nene2\Install 名前空間)に置き、後者だけを製品リポに書きます。

レイヤー 実体 出どころ
.env の原子書き込み・0640・値 escape・JWT secret 生成 Nene2\Install\EnvironmentWriter 共有(vendored)
再設置ガード(マーカー + DB プローブの 2 層) Nene2\Install\ReInstallationGuard 共有(vendored)
CLI 非依存の in-process マイグレーション Nene2\Install\DatabaseSchemaApplier 共有(vendored)
プローブのインターフェース Nene2\Install\ProvisioningProbe 共有(vendored)
書き込む .env キーの集合と順序 InstallEnvironment deal 固有
「設置済みか」を DB で判定 DatabaseProvisioningProbe deal 固有
組織リネーム + dev-seed 削除 + 管理者作成 AdminProvisioner deal 固有
ウィザードの画面(要件 → DB → アプリ → 完了) public_html/install.php + installer.js 兄弟の形を移植

deal 固有に新規で書いたクラスは 3 本、合計 300 行ほどでした(InstallEnvironment 51 行 / DatabaseProvisioningProbe 126 行 / AdminProvisioner 126 行)。兄弟製品も同じ共有ライブラリ(hideyukimori/nene2)に依存していて、それぞれ public_html/install.phpsrc/Install/ を持っています。ウィザードの見た目・遷移・installer.js は先行製品のものをそのまま持ってきて、deal に不要な部品を削っただけです。

つまり「1 日で載った」のは速く書いたからではなく、設置作業のうち危ないところ(秘密の書き込み・再設置・マイグレーション)を先に部品へ固めてあったからです。実装コストのほとんどは、その部品化を済ませた過去の投資でした。

Tier A の置き場所

共有ホスティングでは document root が固定で、vendor/.env を docroot の外に置きたい。なので install.php は public_html/ の中に置き、プロジェクトルートは dirname(__DIR__) で解決します。

$root    = dirname(__DIR__);
$marker  = $root . '/var/.installed';
$envFile = $root . '/.env';

const MIN_PHP = '8.4.1';

CSP(script-src 'self')下で動かすため、クライアント側の挙動(パスワード表示切替・DB アダプタ切替・送信時のローディング)はインラインではなく外部ファイル installer.js に出しています。この JS は完了後も残りますが、静的で秘密を持たないので害はありません。

fail-closed で .env を書く

一番危ないのは .env の書き込みです。DB パスワードと JWT secret が入るファイルを、共有ホストで world-readable のまま置いたら終わりです。ここは共有部品の EnvironmentWriter に任せます。

書き込むキーの集合と順序だけを deal 固有の InstallEnvironment が決め、それを EnvironmentWriter に渡します。

$envValues = InstallEnvironment::values(
    jwtSecret: EnvironmentWriter::generateSecret(32),
    db: $db,
);
(new EnvironmentWriter())->write($envFile, $envValues);

EnvironmentWriter::write() の中身は、隣に一時ファイルを書き、chmod で 0640 にしてから rename する原子書き込みです。ポイントは、0640 にできなかったら例外を投げて止まること。「権限を絞れなかったけど秘密は書いた」という中途半端な状態を作らず、fail-closed に倒します。値は必要に応じてクオートし \"$ を escape するので、スペースや #$ を含むパスワードでも .env の行が壊れたり、余計な行を注入されたりしません。

管理者のメールとパスワードは、この .env マップに意図的に入れていませんInstallEnvironment の docblock にもそう書いてあります。管理者はこのあとインメモリで作るので、.env に休ませる必要がない。

再設置を二層で拒む

一度設置したインスタンスに install.php を再度叩かれて .env を上書きされたり管理者を作り直されたりするのは防がないといけません。共有部品の ReInstallationGuard が二層で守ります。

$reinstallGuard = new ReInstallationGuard(
    $marker,                                              // var/.installed
    DatabaseProvisioningProbe::fromEnvFile($envFile, $root),  // DB プローブ(deal 固有)
);
if ($reinstallGuard->isBlocked()) {
    refuse_install('NeNe Deal は既にインストールされています。…');
}

一層目は var/.installed のマーカーファイル。これが先に見られて短絡します。二層目は DB プローブで、マーカーが消えたとき(再デプロイで ephemeral な var/ が飛ぶ、など)の防御です。プローブのインターフェースだけ共有ライブラリが持ち、「何を見て設置済みと判定するか」は製品ごとに違うので deal 側に書きます。

ここが deal 固有の肝でした。deal のマイグレーションは開発用に「よく知られた資格情報」の operator アカウントを seed します。つまり phinx migrate 直後の users テーブルは空にならない。素朴に「users が 1 件でもあれば設置済み」と判定すると、管理者作成に失敗しただけの半端な状態がロックアウトされて再試行できなくなる。なので dev-seed を除外して数えます。

$statement = $pdo->prepare('SELECT COUNT(*) FROM users WHERE email <> ?');
$statement->execute([AdminProvisioner::DEV_SEED_EMAIL]);
return (int) $statement->fetchColumn() > 0;

このプローブは .env がない・DB に届かない・スキーマがまだない、のいずれでも例外を投げず false を返す(=まっさらなターゲットを設置済みと誤認しない)ように書いてあります。

一度だけ踏んだ罠

.env をパースする方法で一度やられました。最初 parse_ini_file で読んでいたのですが、これは phpdotenv とは方言が違って、# コメントや ( のような文字で Warning を吐きます。共有ホストで display_errors が有効だと、その Warning がガードのヘッダより先に出力されてしまい、本来 403 で止めるべきレスポンスが 200 になって絶対パスまで漏れる

直しは「アプリ本体の ConfigLoader と同じ phpdotenv で読む」に統一しただけです。読めない .env は空の env に degrade させ、マーカー層で守る。

foreach (Dotenv::parse((string) @file_get_contents($envPath)) as $key => $value) {
    if (is_string($value)) {
        $env[$key] = $value;
    }
}

dev-seed の資格情報を必ず消す

.env を書いたら、in-process のマイグレーション(DatabaseSchemaApplier — 共有ホストは exec を止めるので CLI phinx は使えない)を回し、そのあと deal 固有の AdminProvisioner で管理者を作ります。ここが deal 固有の一番の理由です。

マイグレーションは組織 1 個(slug default)と、よく知られた資格情報の operator アカウントを seed します。これを本番に残したら出来合いのバックドアです。AdminProvisioner は 1 トランザクションで、組織をリネームし(slug は据え置き)、その dev アカウントを削除し、フォーム入力から本物の管理者を作ります。

$this->pdo->beginTransaction();
try {
    $organizationId = $this->resolveOrganizationId();

    // 組織名を更新(slug=default は据え置き)
    $this->pdo->prepare('UPDATE organizations SET name = ?, updated_at = ? WHERE id = ?')
        ->execute([$organizationName, $now, $organizationId]);

    // dev-seed の operator を削除
    $delete = $this->pdo->prepare('DELETE FROM users WHERE email = ?');
    $delete->execute([self::DEV_SEED_EMAIL]);
    $devSeedRemoved = $delete->rowCount() > 0;

    // 本物の管理者を作成(password_hash / PASSWORD_DEFAULT)
    // …INSERT INTO users…
    $this->pdo->commit();
} catch (Throwable $e) {
    if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); }
    throw $e;
}

全部を 1 トランザクションにしてあるので、途中でコケたら「マイグレーション済みだが未プロビジョニング」の状態に戻ります。これは前述のプローブが「設置済みでない」と扱う状態なので、そのまま再試行できます。平文パスワードはメモリの中だけを通り、どこにも永続しません

そして完了時、install.php は自分を消します。

if ($result['ok']) {
    $reinstallGuard?->markInstalled(date('c'));
    // …
    // Self-unlink: 設置が完了したらインストーラを残さない。
    @unlink(__FILE__);
}

再アクセスすると 404、ファイルを復元しても marker で 403、marker も消しても DB プローブで 403。どの順で剥がしても設置窓は閉じています。

後日談:手動経路にも同じ穴があった

このインストーラ経由なら dev-seed は同一リクエスト内で消えます。ただ、あとで気づいたのは、ブラウザを使わない手動デプロイ(CLI で phinx を回す経路)だと AdminProvisioner を通らないので、operator アカウントが残ることでした。

そこはインストーラの問題ではなく seed 側の問題なので、マイグレーションの operator seed 自体を明示 opt-in(環境変数フラグ)にして、デフォルトではどこでも seed されない fail-close に変えました。dev スタックの compose だけがそのフラグを立てます。手動経路の初期管理者作成には AdminProvisioner を再利用した CLI (tools/create-admin.php) を用意しました。ここでも「部品を切ってあると横展開が薄い糊で済む」が効いています。

まとめ

論点 どう倒したか 部品
秘密の書き込み 0640 fail-closed・原子書き込み・値 escape 共有 EnvironmentWriter
再設置 マーカー + DB プローブの 2 層 共有 ReInstallationGuard + deal DatabaseProvisioningProbe
共有ホストで exec 不可 in-process phinx マイグレーション 共有 DatabaseSchemaApplier
既知資格情報の残置 インストーラが削除 + seed を opt-in 化 deal AdminProvisioner
インストーラの残置 完了時に自己削除 install.php @unlink(__FILE__)

「1 日で載った」の実体は、危ない処理を先に共有部品へ固めてあったことでした。製品側に書くのは、その製品だけが知っている事情(deal なら「dev-seed の operator を消す」「slug default の org をリネームする」)に絞れる。インストーラのように毎回ほぼ同じで、しかも一箇所間違えると秘密が漏れる類の処理こそ、部品化の配当が大きい領域だと思います。


一次資料

  • NeNe Deal(OSS): インストーラ本体 public_html/install.phpsrc/Install/InstallEnvironment / DatabaseProvisioningProbe / AdminProvisioner
  • 共有部品: Nene2\Installhideyukimori/nene2)の EnvironmentWriter / ReInstallationGuard / DatabaseSchemaApplier / ProvisioningProbe
  • 関連: nene-deal #65(issue)/ #66(実装 PR)

── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?