summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-06-30 22:29:07 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-06-30 22:29:07 +0900
commit58f7917662ff92a3712289acccff8310c8f1baff (patch)
treecf7290a3eaf0e08c170199ac831f562949785014
parent1ad98cc2f9f8a5950aec998a6daacec381cbe2c4 (diff)
Add strongSwan IKEv2 VPN service
- vpn/: Dockerfile, docker-compose.yml, swanctl.conf.tpl, entrypoint.sh - install.sh: Caddy 証明書流用 + コンテナ起動 - renew-cert.sh: 証明書更新 + swanctl ホットリロード - caddy/Caddyfile: vpn.yyamashita.com 追加(Let's Encrypt 証明書取得用) - post-receive: vpn/deploy.sh を追加 - setup-route53.py: WEB_SUBDOMAINS に vpn を追加 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--CLAUDE.md42
-rw-r--r--caddy/Caddyfile6
-rwxr-xr-xgit/hooks/hetzner-infra/post-receive1
-rwxr-xr-xmail/dns/setup-route53.py1
-rw-r--r--vpn/.gitignore2
-rw-r--r--vpn/Dockerfile7
-rw-r--r--vpn/deploy.sh11
-rw-r--r--vpn/docker-compose.yml14
-rw-r--r--vpn/entrypoint.sh54
-rw-r--r--vpn/install.sh59
-rw-r--r--vpn/renew-cert.sh18
-rw-r--r--vpn/strongswan.conf15
-rw-r--r--vpn/swanctl.conf.tpl41
13 files changed, 270 insertions, 1 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 60b2fdc..465d394 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -11,12 +11,13 @@ git/ ベアリポジトリ・フック管理(repos.txt, hooks/, ins
claude/ Claude Code セッション管理(sessions.txt, systemd unit, sync.sh)
hetzner-admin/ サーバー管理 Web UI(app.py, Dockerfile, docker-compose.yml, deploy.sh)
mail/ MTA(Postfix + OpenDKIM, docker-compose.yml, install.sh, dns/setup-route53.py)
+vpn/ strongSwan IKEv2 VPN(Dockerfile, docker-compose.yml, install.sh, renew-cert.sh)
server/ サーバー共通設定(authorized_keys, requirements.md)
```
## デプロイの仕組み
-- `git push origin master` → post-receive フック → `caddy/deploy.sh` / `cgit/deploy.sh` / `mail/deploy.sh` / `hetzner-admin/deploy.sh` を実行
+- `git push origin master` → post-receive フック → `caddy/deploy.sh` / `cgit/deploy.sh` / `mail/deploy.sh` / `hetzner-admin/deploy.sh` / `vpn/deploy.sh` を実行
- cgit の `lighttpd.conf` / `Dockerfile` 変更はイメージ再ビルドが必要(`deploy.sh` で `--build` するため push だけで反映される)
- cgit の `cgitrc` はボリュームマウントのため push だけで反映される
- フック・リポジトリの変更は push 後に `bash /app/infra/git/install.sh` を手動実行
@@ -85,4 +86,43 @@ EOF
bash /app/infra/hetzner-admin/install.sh
# → SSH鍵生成 + authorized_keys登録(command= 制限付き)+ コンテナ起動
# → hetzner-admin.yyamashita.com でアクセス可能
+
+# VPN 初回セットアップ(サーバー上で実行)
+# 前提: ファイアウォールで UDP 500, 4500 を開放済み
+git push origin master
+ssh root@<server> 'bash /app/infra/git/install.sh' # post-receive フック更新
+# → Caddy が vpn.yyamashita.com の証明書を取得するまで数分待つ
+ssh root@<server>
+cat > /app/infra/vpn/users.conf <<EOF
+user1 <strong-password>
+EOF
+chmod 600 /app/infra/vpn/users.conf
+bash /app/infra/vpn/install.sh
+# → Caddy の証明書を流用 + コンテナ起動
+# → vpn.yyamashita.com:500/4500 (IKEv2) で接続可能
+
+# VPN 証明書更新(Let's Encrypt 更新後 90 日ごと)
+ssh root@<server> 'bash /app/infra/vpn/renew-cert.sh'
+
+# VPN ユーザー追加・変更
+ssh root@<server>
+vi /app/infra/vpn/users.conf # 1行1ユーザー: "username password"
+docker compose -f /app/infra/vpn/docker-compose.yml restart
```
+
+## VPN クライアント設定
+
+**iOS / macOS** (ネイティブ IKEv2):
+- サーバー: `vpn.yyamashita.com`
+- タイプ: IKEv2
+- 認証: ユーザー名 + パスワード (EAP)
+- 証明書インストール不要(Let's Encrypt は自動信頼)
+
+**Meta Quest 3S / FireTV** (Android):
+- strongSwan VPN Client アプリをインストール
+ - Quest: sideload `org.strongswan.android` APK
+ - FireTV: 同様に APK をサイドロード
+- プロファイル設定:
+ - タイプ: IKEv2 EAP (Username/Password)
+ - サーバー: `vpn.yyamashita.com`
+ - ユーザー名・パスワード: `users.conf` の内容
diff --git a/caddy/Caddyfile b/caddy/Caddyfile
index 86242b8..66a9d05 100644
--- a/caddy/Caddyfile
+++ b/caddy/Caddyfile
@@ -40,3 +40,9 @@ blog.yyamashita.com {
header X-Robots-Tag "noindex, nofollow"
reverse_proxy microblog-app:3000
}
+
+vpn.yyamashita.com {
+ header Referrer-Policy "no-referrer"
+ header X-Robots-Tag "noindex, nofollow"
+ respond "VPN Server" 200
+}
diff --git a/git/hooks/hetzner-infra/post-receive b/git/hooks/hetzner-infra/post-receive
index 190df69..6eeff49 100755
--- a/git/hooks/hetzner-infra/post-receive
+++ b/git/hooks/hetzner-infra/post-receive
@@ -5,4 +5,5 @@ bash /app/infra/caddy/deploy.sh
bash /app/infra/cgit/deploy.sh
bash /app/infra/mail/deploy.sh
bash /app/infra/hetzner-admin/deploy.sh
+bash /app/infra/vpn/deploy.sh
echo "Deploy complete: hetzner-infra"
diff --git a/mail/dns/setup-route53.py b/mail/dns/setup-route53.py
index 2a6aee9..f42e273 100755
--- a/mail/dns/setup-route53.py
+++ b/mail/dns/setup-route53.py
@@ -37,6 +37,7 @@ WEB_SUBDOMAINS = [
"git",
"hetzner-admin",
"blog",
+ "vpn",
]
diff --git a/vpn/.gitignore b/vpn/.gitignore
new file mode 100644
index 0000000..b68b01a
--- /dev/null
+++ b/vpn/.gitignore
@@ -0,0 +1,2 @@
+certs/
+users.conf
diff --git a/vpn/Dockerfile b/vpn/Dockerfile
new file mode 100644
index 0000000..86644be
--- /dev/null
+++ b/vpn/Dockerfile
@@ -0,0 +1,7 @@
+FROM alpine:3.21
+RUN apk add --no-cache strongswan iptables iproute2 gettext
+COPY swanctl.conf.tpl /etc/swanctl/swanctl.conf.tpl
+COPY strongswan.conf /etc/strongswan.conf
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/vpn/deploy.sh b/vpn/deploy.sh
new file mode 100644
index 0000000..0005d9a
--- /dev/null
+++ b/vpn/deploy.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")"
+
+# 初期化前はスキップ(install.sh を先に実行する必要がある)
+if [ ! -f users.conf ] || [ ! -f certs/server.crt ]; then
+ echo "VPN: 未初期化のためスキップ (install.sh を実行してください)"
+ exit 0
+fi
+
+docker compose up -d --build
diff --git a/vpn/docker-compose.yml b/vpn/docker-compose.yml
new file mode 100644
index 0000000..83ff270
--- /dev/null
+++ b/vpn/docker-compose.yml
@@ -0,0 +1,14 @@
+services:
+ vpn:
+ build: .
+ restart: unless-stopped
+ network_mode: host
+ cap_add:
+ - NET_ADMIN
+ - SYS_MODULE
+ volumes:
+ - ./certs/server.crt:/etc/swanctl/x509/server.crt:ro
+ - ./certs/server.key:/etc/swanctl/private/server.key:ro
+ - ./users.conf:/etc/strongswan/users.conf:ro
+ environment:
+ - VPN_DOMAIN=vpn.yyamashita.com
diff --git a/vpn/entrypoint.sh b/vpn/entrypoint.sh
new file mode 100644
index 0000000..4efe500
--- /dev/null
+++ b/vpn/entrypoint.sh
@@ -0,0 +1,54 @@
+#!/bin/sh
+set -e
+
+# IP フォワーディング有効化
+sysctl -w net.ipv4.ip_forward=1
+sysctl -w net.ipv6.conf.all.forwarding=1
+
+# VPN クライアントのトラフィックを MASQUERADE
+IFACE=$(ip route show default | awk '/default/ {print $5}' | head -1)
+iptables -t nat -C POSTROUTING -s 10.10.0.0/24 -o "${IFACE}" -j MASQUERADE 2>/dev/null || \
+ iptables -t nat -A POSTROUTING -s 10.10.0.0/24 -o "${IFACE}" -j MASQUERADE
+iptables -C FORWARD -s 10.10.0.0/24 -j ACCEPT 2>/dev/null || \
+ iptables -A FORWARD -s 10.10.0.0/24 -j ACCEPT
+iptables -C FORWARD -d 10.10.0.0/24 -j ACCEPT 2>/dev/null || \
+ iptables -A FORWARD -d 10.10.0.0/24 -j ACCEPT
+
+# users.conf から EAP シークレットセクションを生成
+VPN_SECRETS=""
+while IFS=' ' read -r user pass || [ -n "$user" ]; do
+ [ -z "$user" ] && continue
+ case "$user" in \#*) continue ;; esac
+ VPN_SECRETS="${VPN_SECRETS}
+ eap-${user} {
+ id = ${user}
+ secret = \"${pass}\"
+ }"
+done < /etc/strongswan/users.conf
+
+export VPN_SECRETS
+export VPN_DOMAIN="${VPN_DOMAIN:-vpn.yyamashita.com}"
+
+# テンプレートから swanctl.conf を生成
+envsubst < /etc/swanctl/swanctl.conf.tpl > /etc/swanctl/swanctl.conf
+
+# charon (IKE デーモン) 起動
+ipsec start
+
+# 初期化待ち
+sleep 2
+
+# 設定ロード
+swanctl --load-conns
+swanctl --load-creds --noprompt
+swanctl --load-pools
+
+echo "VPN ready. Domain: ${VPN_DOMAIN}"
+
+# シグナルハンドラ
+trap 'ipsec stop; exit 0' TERM INT
+
+# charon が生きている間ループ
+while ipsec status > /dev/null 2>&1; do
+ sleep 10
+done
diff --git a/vpn/install.sh b/vpn/install.sh
new file mode 100644
index 0000000..9a2f33d
--- /dev/null
+++ b/vpn/install.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")"
+
+DOMAIN="vpn.yyamashita.com"
+CADDY_DIR="/app/infra/caddy"
+CERT_DIR="./certs"
+CADDY_CERT_BASE="/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/${DOMAIN}/${DOMAIN}"
+
+echo "=== VPN Install: ${DOMAIN} ==="
+
+# users.conf チェック
+if [ ! -f users.conf ]; then
+ echo ""
+ echo "Error: users.conf が見つかりません。先に作成してください:"
+ echo " echo 'username password' > /app/infra/vpn/users.conf"
+ echo " chmod 600 /app/infra/vpn/users.conf"
+ echo ""
+ echo "複数ユーザーは1行ずつ追加:"
+ echo " user1 pass1"
+ echo " user2 pass2"
+ exit 1
+fi
+
+mkdir -p "$CERT_DIR"
+
+# Caddy コンテナから証明書を取得
+echo "Caddy から証明書を取得中..."
+if ! docker compose -f "$CADDY_DIR/docker-compose.yml" exec -T caddy \
+ cat "${CADDY_CERT_BASE}.crt" > "$CERT_DIR/server.crt"; then
+ echo ""
+ echo "Error: 証明書の取得に失敗しました。"
+ echo "Caddyfile に ${DOMAIN} が追加されているか確認してください。"
+ echo "追加後 'git push origin master' してから再実行してください。"
+ exit 1
+fi
+
+docker compose -f "$CADDY_DIR/docker-compose.yml" exec -T caddy \
+ cat "${CADDY_CERT_BASE}.key" > "$CERT_DIR/server.key"
+
+chmod 600 "$CERT_DIR/server.key"
+echo "証明書を $CERT_DIR/ に保存しました。"
+
+# VPN コンテナ起動
+echo "VPN サービスを起動中..."
+docker compose up -d --build
+
+echo ""
+echo "=== VPN 起動完了 ==="
+echo ""
+echo "クライアント接続情報:"
+echo " サーバー: ${DOMAIN}"
+echo " 種別: IKEv2"
+echo " 認証: EAP (ユーザー名/パスワード)"
+echo ""
+echo "iOS/macOS: 設定 > VPN > VPN 構成を追加 > IKEv2"
+echo "Android (Quest/FireTV): strongSwan VPN Client アプリをインストール"
+echo ""
+echo "証明書の更新: bash /app/infra/vpn/renew-cert.sh"
diff --git a/vpn/renew-cert.sh b/vpn/renew-cert.sh
new file mode 100644
index 0000000..50f8784
--- /dev/null
+++ b/vpn/renew-cert.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")"
+
+DOMAIN="vpn.yyamashita.com"
+CADDY_DIR="/app/infra/caddy"
+CADDY_CERT_BASE="/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/${DOMAIN}/${DOMAIN}"
+
+echo "証明書を更新中..."
+
+docker compose -f "$CADDY_DIR/docker-compose.yml" exec -T caddy \
+ cat "${CADDY_CERT_BASE}.crt" > certs/server.crt
+docker compose -f "$CADDY_DIR/docker-compose.yml" exec -T caddy \
+ cat "${CADDY_CERT_BASE}.key" > certs/server.key
+chmod 600 certs/server.key
+
+docker compose exec vpn swanctl --load-creds --noprompt
+echo "完了。証明書を更新してリロードしました。"
diff --git a/vpn/strongswan.conf b/vpn/strongswan.conf
new file mode 100644
index 0000000..b42f8c0
--- /dev/null
+++ b/vpn/strongswan.conf
@@ -0,0 +1,15 @@
+charon {
+ load_modular = yes
+ plugins {
+ include strongswan.d/charon/*.conf
+ }
+ filelog {
+ stderr {
+ default = 1
+ ike = 2
+ knl = 3
+ }
+ }
+ send_vendor_id = no
+}
+include strongswan.d/*.conf
diff --git a/vpn/swanctl.conf.tpl b/vpn/swanctl.conf.tpl
new file mode 100644
index 0000000..f3a9e2e
--- /dev/null
+++ b/vpn/swanctl.conf.tpl
@@ -0,0 +1,41 @@
+connections {
+ ikev2-eap {
+ version = 2
+ # iOS/macOS/Android 互換の暗号スイート
+ proposals = aes256gcm16-prfsha256-ecp256,aes256-sha256-ecp256,aes256gcm16-prfsha384-ecp384,aes256-sha256-modp2048,aes256-sha1-modp2048
+
+ local {
+ auth = pubkey
+ certs = server.crt
+ id = ${VPN_DOMAIN}
+ }
+
+ remote {
+ auth = eap-mschapv2
+ eap_id = %any
+ }
+
+ children {
+ vpn {
+ local_ts = 0.0.0.0/0
+ esp_proposals = aes256gcm16,aes256-sha256,aes256-sha1
+ dpd_action = clear
+ }
+ }
+
+ pools = pool4
+ send_certreq = no
+ dpd_delay = 30s
+ }
+}
+
+pools {
+ pool4 {
+ addrs = 10.10.0.0/24
+ dns = 1.1.1.1,8.8.8.8
+ }
+}
+
+secrets {
+${VPN_SECRETS}
+}