本文へ移動
vast-cowのブログ
前のページへ戻る

systemd と `certbot` による TLS 証明書自動更新(権限分離を重視)

この記事を編集

この構成は Let’s Encrypt の証明書更新を certbot で自動化し、systemd のサービス/タイマーで定期実行します。セキュリティ上の主眼は「必要最小限の権限で動かす」ことにあり、root 権限はデプロイとサービス再起動など不可避な処理に限定し、外部要因の影響を受けやすい処理は非特権ユーザーへ確実に権限を落として実行します。

セキュリティ設計(権限境界)

root を使うのは“最後の一押し”だけ

スクリプトは EUID==0 を要求しつつ、root が必要な範囲を明確に絞っています。root が必要なのは主に以下です。

逆に、証明書取得やネットワーク関連コマンドは root で実行しません。

外部に近い処理は非特権ユーザーで実行(権限ドロップ)

EXECUTE_USER(例: acmebot)を用意し、次の処理を明示的に sudo -u "$EXECUTE_USER" -H で実行します。

これにより、ACME クライアントや依存ライブラリ、ルータ相手の UPnP 操作など「入力や外部環境に影響されやすい処理」を root 文脈から切り離し、万一の際の影響範囲を抑えます。

systemd による実行制御(最小・予測可能)

サービスユニット

cert-deploy.service は環境変数を EnvironmentFile から読み込み、スクリプトを起動します。PrivateTmp=true により、一時領域の分離が行われ、不要な干渉や情報漏えいのリスクを下げます。

タイマーユニット

cert-deploy.timer は 1 日 2 回実行し、RandomizedDelaySec=1h により実行時間が分散されます。Persistent=true のため、停止中に逃した実行は起動後に追いつきます。

デプロイ時の安全策(無駄な再起動を避ける)

更新があった場合だけデプロイ/再起動

certbot の --deploy-hook でフラグファイルを touch し、フラグが存在する場合のみデプロイと再起動を行います。更新がなければ終了するため、不要な再起動や構成変更リスクを避けられます。

証明書ファイルの健全性チェック

fullchain.pem / privkey.pem / chain.pem が「存在し」「空でない」ことを確認してからコピーします。破損・未生成・部分更新の状態を本番配置先へ伝播しないための基本防御です。

Nginx は再起動前に設定検証

nginx -t を通してから再起動するため、設定エラーによる停止リスクを抑制できます。

HTTP-01 検証の露出を最小化(UPnP の扱い)

HTTP-01 検証のため一時的に WAN:80 → LAN:80 を UPnP で開け、終了時(成功・失敗を問わず)trap で必ず閉じます。さらに UPnP 操作自体は非特権ユーザーで実行するため、外部境界に近い操作を root から遠ざけられます。

依存関係と実行環境の隔離

install.sh は要件コマンド(iproute2 の JSON 出力、upnpc、jq、python3)を事前チェックし、不足があれば停止します。また certbot はシステム Python に入れず、専用の仮想環境(CERTBOT_VENV)に隔離して導入します。非特権ユーザーが書き込む必要のあるディレクトリ(config/work/log/webroot)はそのユーザーに所有させ、デプロイ先の保護ディレクトリは root が管理します。

実運用での追加の締め付け(簡潔)

この構成の要点は、証明書取得とネットワーク操作を非特権で実行し、root は「検証済み成果物の配置」と「サービス制御」に限定することで、攻撃面と影響範囲を最小化している点です。

cert-deploy.service

[Unit]
Description=cert & deploy
After=multi-user.target

[Service]
Type=simple
ExecStart=/usr/libexec/acmebot-certbot/cert-deploy.sh
EnvironmentFile=/etc/acmebot-certbot/env
PrivateTmp=true

[Install]
WantedBy=multi-user.target

cert-deploy.sh

#!/bin/bash

# Must run as root
if (( EUID != 0 )); then
  echo "This script must be run as root. Exiting."
  exit 1
fi

set -xe
set -o pipefail

FLAGFILE="/run/acmebot-certbot/deploy-flag.$(date "+%Y%m%d%H%M%S%N")"
CERTPATH="$CONFIG_PATH/live/$CERT_NAME"

local_ip=""

cleanup() {
  # Always close port even on failure
  echo "[info] upnp port close" 1>&2
  sudo -u"$EXECUTE_USER" -H upnpc -d 80 tcp || true
}
trap cleanup EXIT

echo "[info] getting local address (ip -j route get + jq)" 1>&2

# ip -j route get 1.1.1.1 returns a JSON array; take the first element's "prefsrc"
local_ip="$(ip -j route get 1.1.1.1 | jq -r '.[0].prefsrc // empty')"

if [ -z "$local_ip" ] || [ "$local_ip" = "null" ]; then
  echo "[error] failed to detect local_ip via ip -j route get" 1>&2
  exit 1
fi

echo "[info] local_ip=${local_ip}" 1>&2

# NOTE: This opens WAN:80 -> LAN:80. If you intend WAN:80 -> LAN:8080, change the first "80" to "8080".
echo "[info] upnp port open (WAN 80 -> LAN 80)" 1>&2
sudo -u"$EXECUTE_USER" -H upnpc -a "${local_ip}" 80 80 tcp 600

echo "[info] certbot (webroot)" 1>&2

# Run certbot and capture exit code without aborting the script immediately
set +e
sudo -u "$EXECUTE_USER" -H \
     "${CERTBOT_VENV}/bin/certbot" \
     certonly -q --webroot -w "$WEB_ROOT" \
     --cert-name "${CERT_NAME}" \
     --key-type rsa --rsa-key-size 4096 \
     -d "$DOMAINLIST" \
     --agree-tos --no-eff-email --email "$EMAIL" \
     --deploy-hook "touch '$FLAGFILE'" \
     --config-dir "$CONFIG_PATH" \
     --work-dir "$WORK_DIR" \
     --logs-dir "$LOGS_DIR"
CERTBOT_STATUS=$?
set -e

if [ $CERTBOT_STATUS -ne 0 ]; then
  echo "[warn] Certbot exited with code $CERTBOT_STATUS" 1>&2
  exit $CERTBOT_STATUS
fi

if [ ! -e "$FLAGFILE" ]; then
  echo "[info] cert not renewed. skip deploy/restart." 1>&2
  exit 0
fi

echo "[info] cert renewed. proceed deploy/restart." 1>&2
rm -f "$FLAGFILE"

# Ensure cert files exist and are non-empty
for f in fullchain.pem privkey.pem chain.pem; do
  if [ ! -s "${CERTPATH}/${f}" ]; then
    echo "[error] missing or empty ${CERTPATH}/${f}" 1>&2
    exit 2
  fi
done

echo "[info] put certs" 1>&2

# nginx
cp -t "${NGINX_CERTS}" "${CERTPATH}/fullchain.pem" "${CERTPATH}/privkey.pem"

# strongswan
cp "${CERTPATH}/privkey.pem" "${IPSEC_ETC}"/private/privkey.pem
cp "${CERTPATH}/chain.pem" "${IPSEC_ETC}"/cacerts/chain.pem
cp "${CERTPATH}/fullchain.pem" "${IPSEC_ETC}"/certs/fullchain.pem

echo "[info] nginx config test" 1>&2
nginx -t

echo "[info] restart nginx and strongswan" 1>&2
systemctl restart nginx
systemctl restart strongswan-starter

cert-deploy.timer

[Unit]
Description=Run certbot_update_cert periodically

[Timer]
OnCalendar=*-*-* 00:00:00
OnCalendar=*-*-* 12:00:00
RandomizedDelaySec=1h
Persistent=true

[Install]
WantedBy=timers.target

env.sample

EXECUTE_USER=acmebot

ETC_DIR=/etc/acmebot-certbot
LIBEXEC_DIR=/usr/libexec/acmebot-certbot
SYSTEMD_DIR=/etc/systemd/system
RUN_DIR=/run/acmebot-certbot

CERT_NAME=hoge.ddns.net
DOMAINLIST=hoge.ddns.net
EMAIL=hoge@mail.com

CERTBOT_VENV=/usr/libexec/acmebot-certbot/venv

CONFIG_PATH=/var/lib/acmebot-certbot/config
WORK_DIR=/run/acmebot-certbot/work
LOGS_DIR=/var/log/acmebot-certbot

WEB_ROOT=/var/lib/acmebot-certbot/webroot

NGINX_CERTS=/etc/nginx/pki/certs

IPSEC_ETC=/etc/ipsec.d

install.sh

#!/bin/bash

# Must run as root
if (( EUID != 0 )); then
  echo "[error] This script must be run as root. Exiting."
  exit 1
fi

set -xe

missing=()

# Check if `ip -j address` runs successfully
if ! ip -j address >/dev/null 2>&1; then
    missing+=("ip -j address (iproute2)")
fi

# Check if upnpc exists
if ! command -v upnpc >/dev/null 2>&1; then
    missing+=("upnpc")
fi

# Check if jq exists
if ! command -v jq >/dev/null 2>&1; then
    missing+=("jq")
fi

# Check if python3 exists
if ! command -v python3 >/dev/null 2>&1; then
    missing+=("python3")
fi

# Final check
if [ "${#missing[@]}" -ne 0 ]; then
    echo "The following requirements are missing or not working:"
    for item in "${missing[@]}"; do
        echo " - $item"
    done
    exit 1
fi

. ./env

mkdir -p "$ETC_DIR"
cp -t "$ETC_DIR" ./env

mkdir -p "${RUN_DIR}"
chown "${EXECUTE_USER}" "${RUN_DIR}"

mkdir -p "${LIBEXEC_DIR}"
install --mode=0755 cert-deploy.sh "${LIBEXEC_DIR}"

if [[ ! -x "${CERTBOT_VENV}/bin/certbot" ]]; then
    if [[ ! -x "${CERTBOT_VENV}/bin/python" ]]; then
        if [[ ! -d "${CERTBOT_VENV}" ]]; then
            mkdir -p "${CERTBOT_VENV}"
        fi
        python3 -m venv "${CERTBOT_VENV}"
    fi
    "${CERTBOT_VENV}/bin/pip" install cffi==1.17.1 certbot
fi

mkdir -p "${CONFIG_PATH}"
chown "${EXECUTE_USER}" "${CONFIG_PATH}"

mkdir -p "${WORK_DIR}"
chown "${EXECUTE_USER}" "${WORK_DIR}"

mkdir -p "${LOGS_DIR}"
chown "${EXECUTE_USER}" "${LOGS_DIR}"

mkdir -p --mode=0755 "${WEB_ROOT}"
chown "${EXECUTE_USER}" "${WEB_ROOT}"

install --mode=0644 cert-deploy.service "${SYSTEMD_DIR}"
install --mode=0644 cert-deploy.timer "${SYSTEMD_DIR}"
systemctl daemon-reload

systemctl enable cert-deploy.timer
systemctl start cert-deploy.timer

この記事を編集
この記事を共有:

コメント


前の記事
BUFFALO WSR-3200AX4S 管理画面の自動データ取得について
次の記事
iOSショートカットで「YouTubeリンクをYouTubeアプリで確実に開く」手順