はじめに・動機
家に転がってた4年くらい前に買ったラズパイ互換のなんか(libre computer)が有ったので、VaultwardenとDNS広告ブロッカーを立ててみようと思いました。
最近、Click Fixなんかも多いので悪性ドメインに不用意にアクセスしたくないですし、マルバタイジングを組み合わせたClick Fixとかも防ぎたいっすからね。
あと、フィッシングに引っかかる可能性もあるので、フィッシング耐性のあるパスキーを使いたい。
Vaultwardenでパスキーの管理とかも全部まるっとやりたい。(Googleに登録してもいいけど、自分自身でホストしておくのもいいっすよね、まぁ趣味の問題です)
それではやっていきましょう。
全体のコンセプト
- クラウドっぽさ:家からも、インターネット越しからも自宅にあることを意識せず使えること
- 電気代以外無料であること:運用コストは小さく。ドメイン名は極力取りたくない
- 耐障害性:すべてDockerコンテナにまとめて、環境が最悪壊れても復旧コストを最小にする
- TLS必須:管理画面の通信は自宅内だからといってHTTPで妥協しない
- 攻撃面の最小化:外部公開サービスを限定して、さらにサービスとしてもログインにパスキーを必須とする
技術的制約
IPoEでの接続(事実上のCGNAT)なので従来の着信型の公開は不可能です。
これはリバーストンネル型VPN(Tailscaleなど)を使うことで克服します。
やること
自宅サーバ環境に パスワードマネージャ(Vaultwarden) と 広告ブロッカー(Pi-hole) を導入します。
あとは、Pi-holeの管理コンソールはローカルからアクセスしたいので、ローカルCAを立てた上でクライアントマシンはローカルCAを信頼するようにしておきます。
外部公開はTailscale Funnelを使ってVaultwardenのみを外部に公開します。
作るものを箇条書きでまとめると
- Vaultwarden パスワードマネージャを構築する
- Pi-Hole DNS型広告ブロッカー兼ローカル名解決用DNSを構成する
- nginx でPi-Hole管理画面のTLS終端を担うリバースプロキシを構成する
- Step CA によるPi-Hole管理画面の証明書を発行する(ローカル名の証明書はLets’encryptで発行できないため)
- Tailscale Funnel を利用したグローバルへの公開(Vaultwarden)
環境
項目 | 内容 |
---|---|
ホスト | Raspberry Pi 4 (ARM64) |
OS | Raspbian |
コンテナ実行環境 | Docker CE |
VPN | Tailscale |
証明書管理 | Step CA |
ディレクトリの全体構成
server
├── .env
├── compose.yml
├── etc
│ ├── dnsmasq.d
│ └── pihole
├── logs
├── nginx
│ ├── certs
│ └── conf.d
├── scripts
│ ├── ca-setup.sh
│ ├── cert-renew-and-reload.sh
│ └── create-cert.sh
├── step-ca
└── vw-data
1. Vaultwarden のクロスビルド
Raspberry Pi 向けに Docker イメージを作成する手順です。
# QEMU 準備
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
# buildx ビルダー作成
docker buildx create --name vwbuilder --use
docker buildx inspect --bootstrap
# ソース取得
git clone https://github.com/dani-garcia/vaultwarden.git
cd vaultwarden
VERSION=1.34.3
git switch -d $VERSION
# ARM64 向けビルド
docker buildx build \
--platform linux/arm64 \
-t vaultwarden_arm64:$VERSION \
--load .
生成したイメージを保存して、Raspberry Pi に転送し、イメージをロードして利用します。
Vaultwardenの運用方針・セキュリティ
外部公開するので以下の点にだけ注意すること
- 新規ユーザ登録を無効化する
- 使うユーザは自分自身のみなので、新規追加する必要がない
- ユーザ追加するときはFunnelを無効化してから
- compose.ymlからこのあたりの設定をできるように構成する
- MFAはPasskey必須とする
- フィッシング攻撃によるパスワードマネージャのパスワードの露呈を防ぐため
- TOTPは使わない
- リアルタイムフィッシングに脆弱であるため
- やむを得ない場合には許容するが、基本運用としてはPasskeyを使う
2. Docker Composeの構成
docker compose
でまとめて管理します。
3. Step CA での証明書管理
.envのサンプル
compose.ymlとCA初期化・運用スクリプトと共用です。
.env
# CA 設定
CA_DNSNAME=local-ca.local
CA_NAME=LocalCA
# ディレクトリ(スクリプトの場所を基準に補完)
STEP_CA_DIR=step-ca
CERT_DIR=nginx/certs
# サーバ情報
SERVERS="server.local"
O=Local
OU=Local
# Step CLI / CA
STEP_CA_URL=https://$CA_DNSNAME:8443
STEP_CLI_IMAGE=smallstep/step-cli:latest
STEP_CA_IMAGE=smallstep/step-ca:latest
CAの初期化とサイト毎に証明書を作成するスクリプト
CAの初期化
環境依存の内容は compose.yml
と同じディレクトリの .env
に書いてください。
ca-setup.sh
#!/bin/bash
set -eu
# スクリプトの存在するディレクトリ
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && cd ../ && pwd)"
# .env の読み込み
source "$SCRIPT_DIR/.env"
# 相対パスを補完
STEP_CA_DIR="$SCRIPT_DIR/$STEP_CA_DIR"
CERT_DIR="$SCRIPT_DIR/$CERT_DIR"
PASSWORD_FILE="$STEP_CA_DIR/secrets/password"
mkdir -p "$STEP_CA_DIR/secrets"
docker compose down stepca || true
# 自動パスワード生成
openssl rand -base64 32 > "$PASSWORD_FILE"
chmod 600 "$PASSWORD_FILE"
# Step CA を非対話で初期化
docker run --rm \
-v "$STEP_CA_DIR":/home/step \
"$STEP_CA_IMAGE" \
step ca init \
--deployment-type="standalone" \
--name "$CA_NAME" \
--dns "$CA_DNSNAME" \
--address ":443" \
--provisioner "admin@$CA_DNSNAME" \
--password-file /home/step/secrets/password
サイト毎の証明書作成
create-cert.sh
#!/bin/bash
set -eu
# スクリプトの存在するディレクトリ
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && cd ../ && pwd)"
# .env の読み込み
source "$SCRIPT_DIR/.env"
# 相対パスを補完
STEP_CA_DIR="$SCRIPT_DIR/$STEP_CA_DIR"
CERT_DIR="$SCRIPT_DIR/$CERT_DIR"
PASSWORD_FILE="$STEP_CA_DIR/secrets/password"
mkdir -p "$CERT_DIR"
docker compose up stepca -d
for SERVER in $SERVERS; do
echo "=== request cert for: $SERVER ==="
# CSR / キー作成
docker run --rm -v "$CERT_DIR":/certs alpine:3.18 \
sh -c "apk add --no-cache openssl >/dev/null 2>&1 && \
openssl req -new -newkey ec:<(openssl ecparam -name secp521r1) -nodes \
-keyout /certs/$SERVER.key \
-out /certs/$SERVER.csr \
-subj '/C=JP/O=$O/OU=$OU/CN=$SERVER' \
&& chmod 600 /certs/$SERVER.key && chmod 644 /certs/$SERVER.csr"
# CSR を CA に送信して署名
docker run --rm \
-v "$STEP_CA_DIR":/home/step:ro \
-v "$CERT_DIR":/certs \
--network host \
"$STEP_CLI_IMAGE" \
step ca sign /certs/$SERVER.csr /certs/$SERVER.crt \
--ca-url "$STEP_CA_URL" \
--root /home/step/certs/root_ca.crt \
--password-file /home/step/secrets/password \
--not-after 24h \
--force
done
# nginxのコンテナを潰して作り直す、再起動でもOKです
docker compose down nginx;docker compose up nginx -d
最後、定期的にTLS証明書を自動更新するスクリプト
crontab -e でこれを実行するようにしておくと、12時間ごとに証明書が更新されます。
* */12 * * * /path/to/server/scripts/cert-renew-and-reload.sh > /dev/null 2>&1
cert-renew-and-reload.sh
#!/bin/bash
set -eu
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && cd ../ && pwd)"
source "$SCRIPT_DIR/.env"
# 相対パスを絶対パスに補完
STEP_CA_DIR="$SCRIPT_DIR/$STEP_CA_DIR"
CERT_DIR="$SCRIPT_DIR/$CERT_DIR"
PASSWORD_FILE="$STEP_CA_DIR/secrets/password"
mkdir -p "$CERT_DIR"
changed=false
for SERVER in $SERVERS; do
echo "=== request cert for: $SERVER ==="
CRT_FILE="$CERT_DIR/$SERVER.crt"
KEY_FILE="$CERT_DIR/$SERVER.key"
TMP_CRT="$CERT_DIR/.${SERVER}.crt.tmp"
TMP_KEY="$CERT_DIR/.${SERVER}.key.tmp"
docker run --rm \
-v "$STEP_CA_DIR":/home/step:ro \
-v "$CERT_DIR":/certs \
--network host \
"$STEP_CLI_IMAGE" \
step ca certificate "$SERVER" "/certs/.${SERVER}.crt.tmp" "/certs/.${SERVER}.key.tmp" \
--ca-url "$STEP_CA_URL" \
--root /home/step/certs/root_ca.crt \
--not-after 24h \
--password-file /home/step/secrets/password || {
echo "warning: certificate request failed for $SERVER"
continue
}
chmod 644 "$TMP_CRT" || true
chmod 600 "$TMP_KEY" || true
mv -f "$TMP_CRT" "$CRT_FILE"
mv -f "$TMP_KEY" "$KEY_FILE"
echo "issued/updated: $CRT_FILE $KEY_FILE"
changed=true
done
if [ "$changed" = true ]; then
echo "reloading nginx container..."
docker exec nginx nginx -s reload || {
echo "warning: nginx reload failed"
}
else
echo "no certificate changes; skip reload"
fi
nginxの設定ファイル
# nginx が $connection_upgrade を理解するように map を追加
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name server.local;
ssl_certificate /etc/nginx/certs/server.local.crt;
ssl_certificate_key /etc/nginx/certs/server.local.key;
# Docker の内部DNS を使う(コンテナ内では 127.0.0.11 が Docker DNS)
resolver 127.0.0.11 valid=30s;
# upstream を変数で指定すると起動時に解決しない(リクエスト時に解決される)
set $upstream_host "pihole:80";
location / {
proxy_pass http://$upstream_host;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 等が必要なら以下も
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
4. Tailscale Funnel での公開
Tailscale Funnel を使って VaultwardenをHTTPSで公開します。
tailscale funnel --bg --https=443 http://127.0.0.1:8080/
デーモン化(自動起動)
/usr/local/bin/enable-funnel.sh
を用意し、systemd
ユニットで起動時に Funnel を有効化できます。
#!/bin/bash
# 最大5回まで tailscaled とバックエンドの起動を待つ
for i in {1..5}; do
if systemctl is-active --quiet tailscaled; then
# バックエンドが応答可能か確認
if curl -s http://127.0.0.1:8080/ > /dev/null; then
# Funnel 設定を適用
tailscale funnel --https=443 http://127.0.0.1:8080/
exit 0
else
echo "Backend not responding yet, retrying in 3s..."
sleep 3
fi
else
echo "tailscaled not active yet, retrying in 3s..."
sleep 3
fi
done
echo "Failed to enable funnel after retries"
exit 1
[Unit]
Description=Enable Tailscale Funnel on boot
After=network-online.target tailscaled.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/enable-funnel.sh
RemainAfterExit=true
[Install]
WantedBy=multi-user.target
5. インターネットからPi-HoleにDNS解決させる方法
Vaultwardenはインターネットから使えるようになりました。同じようにPi-holeもインターネットから使いたいです。
ただしDNSをインターネットに公開すると以下のようなリスクがあります。
- 公開キャッシュサーバになることでDNSキャッシュ汚染攻撃を受ける恐れがある
- DNSアンプ攻撃(リフレクション攻撃)に利用される
- ゾーン転送の不正利用(AXFR機能の悪用)
など
オープンリゾルバは上記のようにリスキーです。ではどうするかという話でコンセプトに移ります。
DNSの実装コンセプト
Tailscale(VPN)でスプリットトンネリングを実装します。DNS解決のみをVPN経由でクエリを送信し、それ以外の通信は直接インターネットに流す方式です。
Tailscaleの設定
- Tailscaleの管理画面のDNS設定内の「MagicDNS」を有効にする(デフォルトで有効なはずですが、念のため確認してください)
-
sudo tailscale ip
を確認する - Tailscaleの管理画面のDNS設定内の「Global nameservers」に2で確認したIPアドレスを書く
Tailscale VPNにスマホを参加させて、スプリットトンネルを構成します。
DNSクエリのみをVPNに通過させて、解決させてそれ以外のトラフィックは直接インターネットに出すことでVPN側に不要なトラフィックを発生させません。もう一つのメリットとして通信パケットのカプセル化が必要以上にされないと思うので(諸説)通信(量|料)の節約にもつながるかも。
広告ブロックのみを主眼に置いた構成です。
Pi-Holeの設定
Pi-Hole > SYSTEM > Settings > DNS(DNS Settings画面) で作業をします
DNS Settings
設定モードをExpertモードにしてください(Basicモードだと設定できません)
Interface settings > Potentially dangerous optionsブロックの
Permit all origins を選択します。(まぁファイアウォールで制御していれば問題なしです)
これで、TailscaleからのDNSリクエストをPi-holeで処理できます。
まとめ
- Vaultwarden を ARM 向けにクロスビルドし、Raspberry Pi で稼働させる
- Pi-hole を組み合わせて悪性広告をブロックする
- Step CA によるローカル証明書管理で HTTPS を内部的に完結する
- Tailscale Funnel でVaultwardenのみを外部公開する
この構成により、自宅内外からセキュアに利用できるDNS広告ブロッカーとパスワードマネージャ環境が構築できました。