コマンド集 / OpenTofu

OpenTofu コマンド集

OpenTofu で Cloudflare・Technitium・Proxmox のファイアウォールを宣言し、tofu plan を実機との差分検査にする日々の流れ、取り込み、差分の読み方、提供者ごとの罠、状態の扱いを引ける。

更新

iac/ に Cloudflare(DNS・Access アプリ・SSH/RDP の対象・トンネルの ingress)、Technitium(lab.example.jp の A/CNAME)、Proxmox VE のファイアウォール、WARP のマネージドネットワークを .tf で宣言し、tofu plan を「repo と実機の差分検査」として使う環境向け。差分ゼロが正常で、出たら「実機が変わった」か「宣言を変えた」のどちらか。提供者は cloudflare/cloudflare 5.x、bartei/technitium 1.0.x、bpg/proxmox 0.113.x、hashicorp/external

日々の流れ#

資格情報を環境変数に載せる#

値は 1Password から環境変数に載せるだけで、表示しない。.tf にも state にも秘密を書かない。

export CLOUDFLARE_API_TOKEN="$(op read op://<vault>/<item>/credential)"
export TF_VAR_technitium_api_token="$(op read op://<vault>/<item>/credential)"
export PROXMOX_VE_ENDPOINT="https://<IP>:8006/"
export PROXMOX_VE_API_TOKEN="$(op read op://<vault>/<item>/username)=$(op read op://<vault>/<item>/credential)"
source iac/env.sh

⚠️ PROXMOX_VE_API_TOKENuser@realm!tokenid=<secret> の形。username 欄と credential 欄を = で繋ぐ。echo $CLOUDFLARE_API_TOKEN のような確認をしない。

初期化・整形・検証#

cd iac
tofu init
tofu fmt -recursive
tofu validate

⚠️ プロバイダを足したり版を上げたりしたら tofu init -upgrade.terraform.lock.hcl は repo に置き、.terraform/ は置かない。

差分を見る・当てる#

tofu plan
tofu plan -out=plan.tfplan && tofu apply plan.tfplan
tofu apply

⚠️ apply は Cloudflare / Technitium / PVE の FW を実際に書き換える。公開名を増やす変更は、Entra の redirect URI を人が直したに apply する(先に当てると OIDC が壊れる)。

状態を読む#

一覧と 1 件#

tofu state list
tofu state list | grep cloudflare_zero_trust_access_application
tofu state show cloudflare_zero_trust_tunnel_cloudflared_config.<>

⚠️ state show は属性を全部出す。sensitive = true の変数以外はマスクされないので、出力をそのまま貼らない。

全体と出力値#

tofu show
tofu show -json | jq '.values.root_module.resources[] | {address, type}'
tofu output
tofu output -json

取り込み#

実機の一覧から .tf と import 台本を作る#

最初の取り込みは、API で実機の一覧を JSON に落とし、そこから .tfimports.sh を生成した。一度だけ。 以後は .tf を手で直し、生成台本を二度と回さない。

python3 gen.py
bash imports.sh
bash imports-proxmox.sh
tofu plan

⚠️ 一覧の JSON(_inventory.json)は ID の塊なので repo に置かない。M365 の MX / SPF / DKIM / 検証 TXT は持たない(メールの経路を tofu で壊せる状態にしない)。Workers のルートも持たない(権限が別トークンで、プロバイダは 1 つのトークンしか持てない)。

import の ID 形式(提供者ごと)#

文書に無い形式は試して確かめるしかなかった。

tofu import cloudflare_dns_record.<> "<zone_id>/<record_id>"
tofu import cloudflare_zero_trust_access_application.<> "accounts/<account_id>/<app_id>"
tofu import cloudflare_zero_trust_access_infrastructure_target.<> "<account_id>/<target_id>"
tofu import cloudflare_zero_trust_tunnel_cloudflared_config.<> "<account_id>/<tunnel_id>"
tofu import technitium_record.<> "lab.example.jp::<FQDN>::A::<IP>"
tofu import proxmox_virtual_environment_cluster_firewall.cluster cluster
tofu import proxmox_virtual_environment_cluster_firewall_security_group.<> <グループ>
tofu import proxmox_virtual_environment_firewall_options.<> container/<node>/<ctid>
tofu import proxmox_virtual_environment_firewall_rules.<> vm/<node>/<vmid>

⚠️ Access アプリは accounts/ の接頭辞が要るが、infrastructure target とトンネル設定には要らない。PVE の alias(lab = ラボの CIDR)は取り込みの ID 形式が文書に無いので持たず、規則が名前で参照するだけにしている。

import ブロックで設定を生成する#

1 件ずつ手で書くより、import ブロックから生成させるほうが属性の綴りを間違えない。

import {
  to = cloudflare_dns_record.example
  id = "<zone_id>/<record_id>"
}
tofu plan -generate-config-out=generated.tf

⚠️ 生成された .tf は既定値まで全部書き出す。読める形に削ってから取り込む。

差分の読み方#

取り込み直後の「更新」はプロバイダの正規化#

取り込んだ直後に更新が 29 件出たが、中身は既定値の補完だった。宣言側で明示して消す。

resource "cloudflare_zero_trust_access_application" "example" {
  enable_binding_cookie    = false
  options_preflight_bypass = false
  allowed_idps             = []
  # ...
}
resource "technitium_record" "example" {
  overwrite = false   # 既存レコードを黙って上書きしない
  # ...
}

⚠️ Access の infrastructure 型は policies を id 参照できず、connection_rules 込みで inline に書く。取り込み直後の 1 回だけ apply が要る(中身は同じ)。

期待どおりの差分#

WARP のマネージドネットワークは TLS 証明書の指紋で判定する。指紋は Let’s Encrypt の更新で必ず変わるので、external データソースが openssl で実物を読む。更新後に plan に差分が出るのは正常で、apply で追随する。

data "external" "web_fp" {
  program = ["sh", "-c", "printf '{\"sha256\":\"%s\"}' \"$(echo | openssl s_client -connect <IP>:8006 2>/dev/null | openssl x509 -noout -fingerprint -sha256 | cut -d= -f2 | tr -d : | tr A-F a-f)\""]
}

⚠️ hashicorp/tlstls_certificate は sha1 しか出さない(4.4.1 実測)。

無視したい属性#

実機側で勝手に動く属性は ignore_changes で黙らせる。

lifecycle {
  ignore_changes = [comment]
}

⚠️ 黙らせた属性は差分検査から外れる。「差分ゼロ」の意味が変わるので、理由をコメントに残す。

リソース名を変える#

公開名の改名などでリソース名を変えると、素のままでは destroy + create になる。moved で state を追随させる。

moved {
  from = cloudflare_dns_record.pve_cname
  to   = cloudflare_dns_record.proxmox01_cname
}
tofu state mv cloudflare_dns_record.pve_cname cloudflare_dns_record.proxmox01_cname

⚠️ plan に -/+(replace)が出たら止まる。DNS や Access アプリの作り直しは、その瞬間に無認証で晒すか名前が消えるかのどちらか。

提供者ごとの罠#

Cloudflare(v5)#

v5 はリソース名も属性の形も v4 と別物。ネストは config = { ingress = [ ... ] } のようなオブジェクト。

resource "cloudflare_zero_trust_tunnel_cloudflared_config" "main" {
  account_id = local.account_id
  tunnel_id  = "<tunnel_id>"
  config = {
    ingress = [
      { hostname = "<公開ホスト名>", service = "https://node1.lab.example.jp:8006" },
      { hostname = "<公開ホスト名>", service = "https://localhost:53443", origin_request = { no_tls_verify = true } },
      { service = "http_status:404" },
    ]
  }
}

⚠️ catch-all は末尾。ingress は丸ごと置換なので、1 本消すつもりで全部消さない。provider "cloudflare" {}CLOUDFLARE_API_TOKEN を読むだけで、トークンは 1 つしか持てない。DNS の Edit をゾーン限定にしたトークンで、Workers のルートは触れない。

Technitium(bartei/technitium)#

OpenTofu のレジストリに無いので Terraform のレジストリを明示する。文書に反して TECHNITIUM_* の環境変数を読まない(1.0.1 実測)。

technitium = { source = "registry.terraform.io/bartei/technitium", version = "~> 1.0" }

variable "technitium_api_token" { type = string, sensitive = true }
provider "technitium" {
  server_url      = "https://<IP>:53443"
  api_token       = var.technitium_api_token
  skip_tls_verify = true
}

⚠️ 同じ名前に A が複数あるときは値ごとに別リソース。overwrite = false を明示しないと差分が出続ける。Technitium はクラスタで設定を複製するので、片系にだけ当てる段階投入はできない。

Proxmox VE(bpg/proxmox)#

firewall_options の既定が radv = true / ndp = false で、PVE の既定(radv=0 / ndp=1)と逆。

resource "proxmox_virtual_environment_firewall_options" "g<ctid>" {
  node_name     = "node1"
  container_id  = <ctid>
  enabled       = true
  input_policy  = "ACCEPT"
  output_policy = "ACCEPT"
  dhcp          = false
  ipfilter      = false
  macfilter     = true
  ndp           = true
  radv          = false
  log_level_in  = "nolog"
  log_level_out = "nolog"
}

⚠️ 黙って apply するとゲストの IPv6 の挙動が変わる。 PVE の既定を全部明示してから当てる。apply で PVE が .fw を書き直し、先頭のコメント行は消える(規則のコメントは残る)。API トークンの役割は FW と監査だけにしておき、VM を持つときに足す。VM/LXC 定義は既定値が多く取り込むと差分が出やすいので、触る必要が出たものから。

状態の保護#

この repo での置き方(正直に)#

terraform.tfstateterraform.tfstate.backupプライベートな repo にコミットしている。復旧の媒体が repo だけなので、state を repo の外に置くと復旧できない。秘密はプロバイダの認証を環境変数で渡しているため state に入らない。

コミットする   terraform.tfstate / terraform.tfstate.backup / .terraform.lock.hcl / *.tf
無視する       .terraform/ / _inventory.json / terraform.tfstate.*.backup

⚠️ state には秘密は無いが、ゾーン ID・アカウント ID・トンネル ID・アプリ ID・内部 IP が全部入っている。repo を公開に変えた瞬間に全部出る。 公開先へ写すときは state と _inventory.jsonimports.sh を必ず外す。

手術の前に控えを取る#

state mv / state rm / import の前に、その時点の state を別名で残す。

cp terraform.tfstate "terraform.tfstate.$(date +%F).backup"
tofu state pull > "state-$(date +%F).json"

⚠️ 日付つきの .backup.gitignore で無視される。残したいなら名前を変えるか、git の履歴に頼る。

困ったとき#

ロックが残った#

ローカル state なので、前の tofu が異常終了したときだけ起きる。他に動いているプロセスが無いことを確かめてから外す。

tofu force-unlock <LOCK_ID>

差分が出た#

「実機が変わった」なら .tf を実機に合わせるか、apply で repo に合わせる。どちらが正しいかは変えた理由で決まるので、先に作業記録の日付を引く。

tofu plan -no-color | grep -E '^\s*[~+-]'
tofu plan -target=cloudflare_dns_record.<名前>

⚠️ -target は「その 1 件だけ」を当てるための道具で、常用しない。残りの差分が見えなくなる。

1 件だけ作り直す・忘れる#

tofu apply -replace=cloudflare_zero_trust_access_application.<名前>
tofu state rm cloudflare_dns_record.<>

⚠️ taint は非推奨で -replace を使う。state rm は実機を消さず state から忘れるだけ。もう一度持ちたければ import し直す。

apply の後で公開経路のログインが壊れた#

tofu の差分は Cloudflare 側しか見ていない。Entra の redirect URI・PVE の OIDC レルム・Technitium の SSO の返り先は tofu の外にある。公開名を足す/改名する変更は、先に人がそちらを直してから apply する。