ESP32温度監視を「安全に」クラウド集約する:リバースプロキシ→FastAPI→MariaDB→Exment(クセ強OAuth2)までの全体像と構築手順

リバースプロキシ→FastAPI→MariaDB→Exment(クセ強OAuth2)までの全体像と構築手順 Uncategorized
リバースプロキシ→FastAPI→MariaDB→Exment(クセ強OAuth2)までの全体像と構築手順

工場や現場の温度監視(HACCP・衛生管理)をIoT化しようとすると、 「ESP32で測る」だけでは終わりません。
送信経路の安定化、外部公開の安全性、DB設計、そして最終的な帳票・記録システム(Exment等)への連携まで、 意外と“地雷”が多いです。

この記事では、実際に運用している構成をベースに、 ESP32 → リバースプロキシ → APIサーバー(FastAPI/Uvicorn)→ MariaDB → Exment という一連の流れを、全体像 → つまずきポイント → 構築手順 → Exment連携 → セキュリティ → 備忘録テンプレの順で整理します。
URLや鍵情報はダミーに置き換えています(実運用では必ず置き換えてください)。


  1. 1. 全体像:データはどこを通って、どこで守るのか
    1. 1-1. システム構成(概念図)
    2. 1-2. 役割分担(重要)
  2. 2. つまずきポイント(現場で起きやすい問題)
    1. 2-1. 「内部ドメイン」の誤解
    2. 2-2. APIキーをどこで使うのかが混乱する
    3. 2-3. Exment APIが「普通のAPIキー方式」じゃない
  3. 3. 構築手順:ESP32 → リバプロ → FastAPI → MariaDB
    1. 3-1. APIサーバー(FastAPI/Uvicorn)の最小実装例
    2. 3-2. .env(設定はsystemdで読み込む)
    3. 3-3. systemdで常駐化(venvで動かす)
    4. 3-4. MariaDB(テーブル例)
  4. 4. リバースプロキシ(Nginx):入口URLを作る
  5. 5. Exment連携が最大の地雷:APIキー「だけ」では絶対に動かない
    1. 5-1. Exmentで必要な“発行物”はAPIキーだけじゃない(3点セット)
    2. 5-2. 認証は2段階:トークン取得 → トークンでAPI呼び出し
      1. Step1:トークン取得(ダミー値)
      2. Step2:Bearerでデータ書き込み(table_idで指定)
    3. 5-3. よくある誤解:こうすると失敗する
      1. ❌ APIキーを“直接”ヘッダーに入れる(一般的RESTのノリ)
      2. ❌ OAuth2標準の grant_type=client_credentials を使う
    4. 5-4. Exmentのもう1つの罠:権限は“APIキー作成者”で事故りやすい
  6. 6. 「複数APIキー」「ユーザー別権限」をどうするか(TempMon API側)
    1. 6-1. 最小強化:複数キー(同一権限)
    2. 6-2. 本命:DBにキーとスコープ(権限)を持つ
  7. 7. セキュリティ設計:APIキー/シークレット/トークンを「どこに持たせるか」問題
    1. 7-1. 何が秘密情報なのか(リスクレベル順)
    2. 7-2. “置き場所”の推奨構成
    3. 7-3. トークンは「キャッシュ」と「更新」がセット
    4. 7-4. 端末(ESP32)に鍵を持たせる場合の現実解
      1. 案A:端末ごとの個別APIキー(強く推奨)
      2. 案B:署名方式(HMAC)
    5. 7-5. サーバー側で秘密情報を守る最低限の設定
  8. 8. まとめ:この構成が強い理由
  9. 付録:備忘録テンプレ(ダミーで記録しておくと後で助かる)
    1. サーバー一覧
    2. APIサーバー(FastAPI)
    3. DB
    4. Exment(API)

1. 全体像:データはどこを通って、どこで守るのか

1-1. システム構成(概念図)


[ESP32 + DS18B20]
   |
   | HTTPS POST /api/telemetry (X-API-Key を付与)
   v
[Reverse Proxy (Nginx) / Public Entry]
  tempmon-api.example.work
   |
   | proxy_pass http://API_SERVER:8000/api/telemetry
   v
[API Server (FastAPI/Uvicorn)]
  /opt/tempmon-api (venv)
   |
   | INSERT INTO MariaDB (TemperatureDB.esp32_readings)
   v
[MariaDB (SQL Server)]
  TemperatureDB
   |
   | (バッチ/ジョブ/手動) Exmentへ転送
   v
[Exment]
  OAuth2 (grant_type=api_key) → Bearer Token → /admin/api/data/{table_id}
  

1-2. 役割分担(重要)

  • ESP32:温度計測+送信(再送・時刻同期など)
  • リバースプロキシ:外部公開の入口(TLS終端、制限、ログ)
  • APIサーバー(FastAPI):受信データ検証→DB書込(例外処理・冪等性)
  • MariaDB:生データの蓄積(後から集計・再送・補正が可能)
  • Exment:運用記録(帳票・検索・権限・現場運用UI)

ポイントは、「生データはまずDBに落とす」こと。
Exment(や他のSaaS/業務DB)へ直送すると、通信失敗時の復旧や再送が面倒になりやすいです。
まずSQLに集約し、後段を“いつでも再実行できる転送”にしておくと運用が安定します。


2. つまずきポイント(現場で起きやすい問題)

2-1. 「内部ドメイン」の誤解

https://tempmon-api.example.work/api/telemetry は “内部用” ではなく、 ESP32が外部から叩く入口URLです。
内部なのは、リバプロが裏側で叩く http://API_SERVER:8000 の方です。

2-2. APIキーをどこで使うのかが混乱する

APIキーは次の2点で混乱します:

  • 送る側:ESP32(またはリバプロ)がヘッダーに載せる
  • 検証する側:APIサーバー(FastAPI)が照合する

本記事の構成では、ESP32が入口URLへ送信し、APIサーバー側でAPIキーを照合します。
※リバプロで検証する設計も可能ですが、まずは構成を単純にしてから強化する方がトラブルが減ります。

2-3. Exment APIが「普通のAPIキー方式」じゃない

Exmentは、よくある X-API-Key: xxx だけで叩けるAPIではなく、 OAuth2(ただし grant_type がカスタム)の2段階です。

  • Step1:grant_type=api_keyBearerトークン取得
  • Step2:トークンを Authorization: Bearer ... で付けてAPI呼び出し

3. 構築手順:ESP32 → リバプロ → FastAPI → MariaDB

3-1. APIサーバー(FastAPI/Uvicorn)の最小実装例

以下は実運用パターンを簡略化した例です(ダミー値)。


# /opt/tempmon-api/app.py (例)

from fastapi import FastAPI, Request, Header, HTTPException
from pydantic import BaseModel, Field
from typing import List, Optional
import os
import pymysql

DB_HOST = os.getenv("DB_HOST", "127.0.0.1")
DB_PORT = int(os.getenv("DB_PORT", "3306"))
DB_NAME = os.getenv("DB_NAME", "TemperatureDB")
DB_USER = os.getenv("DB_USER", "tempapi")
DB_PASS = os.getenv("DB_PASS", "")
API_KEY = os.getenv("API_KEY", "")  # 固定キー(まずは1本)

TABLE = "esp32_readings"
app = FastAPI(title="TempMon Telemetry API", version="1.0")

class Reading(BaseModel):
    sensor_hw_id: str = Field("", max_length=16)
    raw_celsius: float
    offset_c: float = 0.0
    celsius: float

class Telemetry(BaseModel):
    device_id: str = Field(..., max_length=32)
    recorded_at: str = ""   # "YYYY-MM-DD HH:MM:SS"
    ts_device: int          # epoch_ms
    rssi: Optional[int] = 0
    readings: List[Reading]

def get_conn():
    return pymysql.connect(
        host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASS,
        database=DB_NAME, charset="utf8mb4", autocommit=True,
        cursorclass=pymysql.cursors.DictCursor,
    )

@app.get("/health")
def health():
    return {"ok": True}

@app.post("/api/telemetry")
async def telemetry(req: Request, x_api_key: Optional[str] = Header(default="")):
    # ★APIキー照合(入口の最低限の防御)
    if API_KEY and (not x_api_key or x_api_key != API_KEY):
        raise HTTPException(status_code=401, detail="invalid api key")

    payload = await req.json()
    data = Telemetry(**payload)

    if not data.readings:
        raise HTTPException(status_code=400, detail="no readings")

    src_ip = (req.client.host if req.client else "") or ""

    sql = f"""
    INSERT IGNORE INTO {TABLE} (
      device_id, recorded_at, ts_device,
      sensor_hw_id, raw_celsius, offset_c, celsius,
      src_ip, rssi
    ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
    """

    inserted = 0
    with get_conn() as conn:
        with conn.cursor() as cur:
            for r in data.readings:
                cur.execute(sql, (
                    data.device_id, data.recorded_at, int(data.ts_device),
                    (r.sensor_hw_id or "")[:16],
                    float(r.raw_celsius), float(r.offset_c), float(r.celsius),
                    src_ip, int(data.rssi or 0),
                ))
                if cur.rowcount == 1:
                    inserted += 1

    return {"ok": True, "inserted": inserted, "readings": len(data.readings)}
  

3-2. .env(設定はsystemdで読み込む)


# /opt/tempmon-api/.env(例:値はダミー)

DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=TemperatureDB
DB_USER=tempapi
DB_PASS=************
API_KEY=************   # まずは固定キー1本(複数化は後述)
  

3-3. systemdで常駐化(venvで動かす)


# /etc/systemd/system/tempmon-api.service(例)

[Unit]
Description=TempMon API (FastAPI/Uvicorn)
After=network.target mariadb.service

[Service]
Type=simple
WorkingDirectory=/opt/tempmon-api
EnvironmentFile=/opt/tempmon-api/.env

ExecStart=/opt/tempmon-api/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8000 --log-level info

Restart=always
RestartSec=2
User=tempmon
Group=tempmon

[Install]
WantedBy=multi-user.target
  

確認コマンド:


sudo systemctl daemon-reload
sudo systemctl enable --now tempmon-api
sudo systemctl status tempmon-api --no-pager -l
journalctl -u tempmon-api -n 200 --no-pager
  

3-4. MariaDB(テーブル例)


CREATE TABLE esp32_readings (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  device_id VARCHAR(32) NOT NULL,
  recorded_at VARCHAR(19) NOT NULL,
  ts_device BIGINT NOT NULL,

  sensor_hw_id VARCHAR(16) NOT NULL,
  raw_celsius DOUBLE NOT NULL,
  offset_c DOUBLE NOT NULL,
  celsius DOUBLE NOT NULL,

  src_ip VARCHAR(64) NOT NULL,
  rssi INT NOT NULL DEFAULT 0,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

  UNIQUE KEY uq_1 (device_id, ts_device, sensor_hw_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  

UNIQUE KEY + INSERT IGNORE により、再送や二重送信が起きてもDBが壊れにくくなります(冪等性)。


4. リバースプロキシ(Nginx):入口URLを作る

ESP32が叩くURL https://tempmon-api.example.work/api/telemetry は、 リバプロ側で「そのドメインの受け口」を作り、裏側のAPIへ転送します。


server {
  listen 443 ssl;
  server_name tempmon-api.example.work;

  # TLS設定は省略(Let's Encrypt等)

  location /api/telemetry {
    proxy_pass http://API_SERVER:8000/api/telemetry;

    # 方式A:ESP32が送るX-API-KeyをそのままAPIへ渡す(ESP32にキーを持たせる)
    proxy_set_header X-API-Key $http_x_api_key;

    # 方式B:リバプロが固定で付与(ESP32にキーを持たせない)
    # proxy_set_header X-API-Key "************";

    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $remote_addr;
  }
}
  

推奨の追加防御:IP制限・レート制限(ESP32台数が少ないほど効きます)。


5. Exment連携が最大の地雷:APIキー「だけ」では絶対に動かない

ここがこの構成で一番ハマるポイントです。
Exmentは「APIキーを発行して、そのキーをヘッダーに付けて叩けばOK」という一般的なREST APIとは違います。
APIキーは“直接使う鍵”ではなく、アクセストークン(Bearer)を作るための材料です。

5-1. Exmentで必要な“発行物”はAPIキーだけじゃない(3点セット)

  • client_id(OAuthクライアントID)
  • client_secret(OAuthクライアント秘密鍵)
  • api_key(Exmentが発行するAPIキー)

「APIキーを作ったのに動かない」という場合の多くは、
client_id / client_secret が手元に無い、あるいは 組み合わせが違うのが原因です。
※この3つはセットで管理してください(どれか1つ欠けても認証できません)。

5-2. 認証は2段階:トークン取得 → トークンでAPI呼び出し

  1. Step1:アクセストークンを取得
    POST /admin/oauth/token
    ここで grant_type="api_key" というカスタムgrant_typeを使います。
    標準の client_credentials は通りません。
  2. Step2:BearerトークンでAPI呼び出し
    Authorization: Bearer {access_token} を付けて
    POST /admin/api/data/{table_id} に書き込みます。

Step1:トークン取得(ダミー値)


curl -sS -X POST "https://exment.example.work/admin/oauth/token" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "api_key",
    "client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "client_secret": "************",
    "api_key": "key_************",
    "scope": "me value_read value_write"
  }'
  

Step2:Bearerでデータ書き込み(table_idで指定)


TOKEN="eyJ0eXAiOiJKV1QiLCJhbGciOi..."
TABLE_ID="91"

curl -sS -X POST "https://exment.example.work/admin/api/data/${TABLE_ID}" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "value": {
      "unique_key": "ESP32_ABC_20260215_120000",
      "check_slot": "2026-02-15 12:00:00",
      "temp_c": 23.5,
      "device_id": "esp32-01"
    }
  }'
  

注意:Exmentは テーブル名ではなく table_id を使うことが多いです。
また、POSTボディは {"value": {...}} でラップが必須になりやすいです。

5-3. よくある誤解:こうすると失敗する

❌ APIキーを“直接”ヘッダーに入れる(一般的RESTのノリ)


curl -X POST "https://exment.example.work/admin/api/data/91" \
  -H "X-API-Key: key_************" \
  -d '{ "value": { ... } }'
  

→ 多くのケースで 401 Unauthorized
ExmentのAPIキーはBearerトークンの材料であり、直接の認証キーではありません。

❌ OAuth2標準の grant_type=client_credentials を使う


{
  "grant_type": "client_credentials",
  "client_id": "...",
  "client_secret": "..."
}
  

400 Bad Request になりがち。
Exmentは grant_type=”api_key”(カスタム)を使います。

5-4. Exmentのもう1つの罠:権限は“APIキー作成者”で事故りやすい

ExmentのAPIは、実行主体の権限がないと403になります。
そのため、管理者(Admin)でAPIキーを発行して運用すると、 APIが「実質Admin権限で動けてしまう」状態になりがちです。

推奨は、API専用ユーザー(サービスアカウント)を作り、 必要最小限の権限(例:特定テーブルの追加だけ)を付けたグループに所属させて、 そのユーザーでAPIキー(+クライアント)を発行することです。


6. 「複数APIキー」「ユーザー別権限」をどうするか(TempMon API側)

現状のFastAPIサンプル(固定キー1本)は、複数端末・権限差に弱いです。
将来を見据えるなら、次の順で強化するのが現実的です。

6-1. 最小強化:複数キー(同一権限)


# .env(例)
API_KEYS=key_a,key_b,key_c
  

ただし、この方式は「鍵ごとの権限」を持てません(全部同じ扱い)。

6-2. 本命:DBにキーとスコープ(権限)を持つ

端末ごとに鍵を失効したい、閲覧用と書き込み用を分けたい…などが出てきたら、 DBでキー管理が運用上ラクです。

  • api_keys:key_hash / enabled / owner / role
  • api_key_scopes:telemetry:write / telemetry:read 等

これにより「このキーは書き込みだけ」「このキーは閲覧だけ」などが実現できます。
(Exment側も同様に“専用ユーザー+最小権限”が安全です)


7. セキュリティ設計:APIキー/シークレット/トークンを「どこに持たせるか」問題

この手のIoT連携で本質的に難しいのは、 秘密情報(APIキーやclient_secret)をどこに置くかです。
一度でも漏れたら「そのキーで出来ること全部」が乗っ取られるため、 実装前に“置き場所”を決めておくのが重要です。

7-1. 何が秘密情報なのか(リスクレベル順)

  • 最重要(漏洩=即時全権奪取):Exmentの client_secret、(存在するなら)ExmentのAPIキー一式
  • 重要(漏洩=不正操作/改ざん):入口API(TempMon)のAPIキー(X-API-Key)、Exmentの access_token
  • 準重要:エンドポイントURL、デバイスID

結論:Exmentのclient_secretは絶対に端末(ESP32)に持たせないのが鉄則です。

7-2. “置き場所”の推奨構成

  1. ESP32(エッジ):できれば秘密情報を持たない(持つなら端末固有キー)
  2. APIサーバー(中継層):入口認証とバリデーション、DB書き込み
  3. 転送ジョブ(バックエンド):Exmentの client_secret 等を保持し、SQL→Exment を担当

こうすると、仮にESP32が盗難・解析されても Exmentを直接叩ける秘密情報は漏れないため被害が限定されます。

7-3. トークンは「キャッシュ」と「更新」がセット

Exmentはトークン取得が必要なため、毎回取得すると負荷が高まります。
そこで access_token をキャッシュしますが、保存場所は慎重に選びます。

  • OK:サーバーのメモリ内キャッシュ(プロセスが落ちれば消える)
  • OK:サーバーのローカルファイル(権限600、root/専用ユーザーのみ)
  • 注意:DB平文保存(漏洩時の説明責任が重い)
  • NG寄り:ESP32のフラッシュに長期保存(盗難/解析リスク)

7-4. 端末(ESP32)に鍵を持たせる場合の現実解

案A:端末ごとの個別APIキー(強く推奨)

端末IDとAPIキー(ハッシュ化)をサーバー側で管理し、 漏洩端末だけ失効できるようにします。

案B:署名方式(HMAC)

HMAC(timestamp + payload) で署名し、鍵を直接送らない方式。
ただし正確な時刻同期が必要になります。

7-5. サーバー側で秘密情報を守る最低限の設定


# .envファイルを守る(例)
sudo chown tempmon:tempmon /opt/tempmon-api/.env
sudo chmod 600 /opt/tempmon-api/.env

# systemdの実行ユーザー確認
systemctl show tempmon-api -p User -p Group -p EnvironmentFile
  

8. まとめ:この構成が強い理由

  • ESP32は軽く、入口URLに投げるだけ
  • リバプロで外部公開を安全に(TLS/制限/ログ)
  • FastAPIで受信検証・冪等INSERT・例外処理
  • SQLに生データを残すので、後から再送・補正・集計が可能
  • Exmentはクセ強認証でも、SQL→Exmentへ分離すると運用が安定

付録:備忘録テンプレ(ダミーで記録しておくと後で助かる)

サーバー一覧

  • Reverse Proxy:PUBLIC_RP_IP / ドメイン:tempmon-api.example.work
  • API/SQL Server:API_SQL_IP(FastAPI:8000, MariaDB:3306)
  • Exment:exment.example.work

APIサーバー(FastAPI)

  • WorkingDirectory:/opt/tempmon-api
  • venv:/opt/tempmon-api/venv
  • service:/etc/systemd/system/tempmon-api.service
  • .env:/opt/tempmon-api/.env
  • Endpoint:POST /api/telemetry / GET /health

DB

  • DB名:TemperatureDB
  • テーブル:esp32_readings
  • DBユーザー:tempapi(権限:INSERT/SELECT等、必要最小)

Exment(API)

  • Token取得:POST /admin/oauth/token(grant_type=api_key)
  • 書き込み:POST /admin/api/data/{table_id}
  • 必要:client_id / client_secret / api_key / scope
  • 推奨:専用ユーザー(サービスアカウント)で発行し最小権限にする

コメント

タイトルとURLをコピーしました