Bitwarden スマホアプリから Vaultwarden につなげなくなったら

Bitwarden の API spec が変更になったらしく、しばらくは新旧いずれの spec でもアクセス可能だったが、 今のスマホクライアントでは新 spec しか対応していない (らしい)。

Vaultwarden を Version 1.31.0 以上にアップグレードする必要がある。

github.com

ref. Change API and structs to camelCase by dani-garcia · Pull Request #4386 · dani-garcia/vaultwarden · GitHub

Traefik から Envoy Gateway に乗り換えた

以前書いたように、おうち k8s クラスタではクラスタ外からのアクセスのために Traefik を導入していた。

dayflower.hatenablog.com

ところが、 (最初はうまくいっていたものの) あるときから Helm でインストールした MySQLIngressRouteTCP で外部からアクセスできなくなってしまった。

(telnet で接続してみたところ通常 server から接続直後に応答があるべきところ client からなんらかのパケットを送らないとセッションが開始しない)

いろいろ調べてみたがどうにもうまくいかず、せっかくなので別のソフトウェアに乗り換えることにした。

Istio にしようかとも思ったが、サービスメッシュのような複雑なことはするつもりはなくコンパクトなものがいいなと思い、 同じ Envoy ベースの Envoy Gateway にすることにした。

Traefik Proxy Envoy Gateway
ベース Traefik Proxy Envoy
Ingress Controller ×
Gateway API
独自 CRD
TLS 証明書管理 ×

星取表でみるかぎり完全に Traefik に負けてしまうのだが、裏を返すと限定した機能にフォーカスしているといえる。

Ingress に対応していないのがちょっとつらいところだが、 Gateway API のほうが実現できる機能も多いため、 Envoy Gateway でいくことにする。 Envoy に興味もあったし。

インストール

Helm でインストールした。

Traefik とちがい、 values はデフォルトからいじっていない。

これは、 Traefik にくらべて TLS 証明書管理機能がないこと、また、対象とするポート等の設定は Resource として定義することから、本体自体のカスタマイズは特に必要なかったことなどが理由と思う。

Argo CD での Helm インストール

CRD が大きすぎて、デフォルトだと Argo CD で管理できない (Too long: must have at most 262144 bytes のように怒られる)。

適当に調べて Replace mode で配布するようにしてみたが、それでもうまくいかなかったので、結局手で helm install することにした。

いま改めて調べると Replace ではなく Server Side Apply を利用するほうがよいらしい (ref. Fixing Argo CD "Too long must have at most 262144 bytes" error)。

時間ができたらふたたび Argo CD による配布に挑戦してみようと思う。

(2025-03-14 追記: spec.syncPolicy.syncOptionsServerSideApply=true を付与することで、無事 Argo CD で管理できるようになった)

セットアップ

cert-manager による TLS 証明書管理

Traefik はいい感じにTLS の証明書を管理してくれる (ACME 対応の認証局であれば自動発行も可能) が、 Envoy Gateway にはそのような機能はない。

なので、定番である cert-manager を利用することにする。

自分は k8s 基盤として MicroK8s を利用しているので cert-manager addon を enable してインストールした。

(MicroK8s での説明 だと、あたかも Ingress 環境があることが必須のように読めてしまうが、 ACME DNS01 Challenge をするぶんには Ingress は必要ではない)

Let's Encrypt で *.wildcard.example.com の証明書を発行する resource は以下のようになる。

---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  namespace: default
  name: wildcard.example.com
spec:
  issuerRef:
    kind: ClusterIssuer
    name: luadns-issuer
  secretName: wildcard.example.com-tls
  commonName: wildcard.example.com
  dnsNames:
    - wildcard.example.com
    - "*.wildcard.example.com"

metadata.namespec.secretName はなんでもかまわない。

spec.issueRef はこのあいだの記事で定義した ClusterIssuer を参照している。

dayflower.hatenablog.com

spec.secretName で指定した Secret に証明書が格納される。 これを後述する Gateway から参照することになる。

GatewayClass の定義

Gateway をハンドリングする controller を指定する GatewayClass を定義する。

Ingress における IngressClass と同じようなものと思えば問題ない。

---
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: envoy-gateway
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller

Envoy Gateway (Controller) の設定をカスタマイズするためには、以下のような記述になる。 (オフィシャルの example が参考になる)

---
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: envoy-gateway
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
  parametersRef:
    group: gateway.envoyproxy.io
    kind: EnvoyProxy
    namespace: envoy-gateway-config
    name: config
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
  namespace: envoy-gateway-config
  name: config
spec:
  ...

spec に設定可能なパラメータは オフィシャルドキュメントの EnvoyProxySpec を参照のこと。

とはいえ、通常の場合わざわざカスタマイズする必要もなく、 GatewayClass だけ定義すればよいと思う。

Gateway の定義

Traefik の場合、どの port を開けるかといった設定はすべて起動時オプションで指定する必要があった (Helm でインストールする場合は values.yaml で指定)。

Gateway API ではそういった port 設定もすべて CRD になっている。 具体的には Gateway resource を利用する。

---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  namespace: default
  name: envoy-gateway
spec:
  gatewayClassName: envoy-gateway
  addresses:
    - type: IPAddress
      value: 192.168.0.100
  listeners:
    - name: https
      protocol: HTTPS
      port: 443
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            namespace: default
            name: wildcard.example.com-tls
      allowedRoutes:
        kinds:
          - kind: HTTPRoute
        namespaces:
          from: All
  • spec.gatewayClassName に上記で設定した GatewayClass の name を指定する
    • Ingress や PersistentVolume に似ている
  • spec.listeners[].tls.certificateRefs に上記で設定した cert-manager による Certificate resource により生成される Secret resource を指定している
  • spec.listeners[].allowedRoutes.kinds に、当該 Gateway に利用可能な route resource を指定している (後述)
  • spec.addresses は、External LoadBalancer (metallb 等) が適切にセットアップされていれば、とくに設定する必要はない
    • 筆者の環境では (MicroK8s に) metallb は導入しておらず、 single node の物理 I/F にアサインされたアドレスをそのまま利用しているので、 IP アドレスを明示的に指定している

ルーティングの設定

詳細な説明は不要かと思うが、実際の Service に対応する HTTPRoute resource の定義はたとえば以下のようになる。 (ちなみに HTTPS であっても HTTPRoute を利用する。HTTPSRoute はない)

---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: httproute
spec:
  parentRefs:
    - name: envoy-gateway
      namespace: default
      sectionName: https
  hostnames:
    - 'nantoka.wildcard.example.com'
  rules:
    - backendRefs:
        - name: service-name
          port: 80
      matches:
        - path:
            type: PathPrefix
            value: /
  • spec.parentRefs に上記で設定した Gateway resource を指定する
    • spec.parentRefs[].sectionNameGateway resource の listener で設定した name を指定する
  • spec.rules[].backendRefsトラフィックをルーティングする Service を指定する

感想

以下に Traefik から乗り換えてよかったところ、残念だったところをあげる。

よかったところ

  • 標準にのっとっている安心感 (まだ core ではないが)
    • 実際には Traefik も標準 Ingress resource / Gateway API resource を提供しているが
  • 設定まわりがきちんと CRD として定義されており、管理がしやすい

残念なところ

  • 設定が CRD なので、裏をかえすと、利用したい機能にたいして、定義するべきリソースが増える
  • Ingress がない
    • これはそもそも Envoy Gateway のポリシーだからあたりまえではある。だが、
    • 他の Helm chart 等で Ingress 設定を指定できるケースがあり、その場合でも別途 HTTPRoute resource を自力で定義しないといけないのがめんどくさい
  • Traefik とちがい Web の status dashboard がない
    • じゃあ実際に使うのかというと使わないのだが、あればあったで登録されている route を web から一覧でみれて便利

cert-manager で let's encrypt (ACME) の証明書を LuaDNS を利用しつつ発行する

Let's Encrypt の証明書を発行するフローには ACME HTTP-01 と DNS-01 の2つがある。

HTTP-01 の場合、事前に 80 番ポートで対象となるドメインにファイルを設置する必要があり、またワイルドカード証明書に対応できない (と、思う)。

DNS-01 の場合は対象ドメインDNS の TXT レコードに特定の内容を設定する必要があるが、発行にあたって事前に HTTP 通信を行う必要がないし、ワイルドカード証明書も発行できる。

cert-managerk8s 上で TLS 証明書の取得や更新をするものであり、 ACME DNS-01 に対応している (たぶん HTTP-01 にも対応している)。

DNS の設定をおこなう必要があるが、各種 DNS プロバイダ にオフィシャルに (ビルトインで) 対応している、が、リストをみてもらえるとわかるとおり、有名どころはあるがその数は少ない。

さまざまな DNS プロバイダへの対応コードを cert-manager に組み込んでいくとメンテナンスやクオリティコントロールの面で課題があるので、 cert-managerWebhook Issuer という形で、外部から拡張可能にしているらしい。

野良を含め Webhook Issuer は https://github.com/topics/cert-manager-webhook にリストアップされている。 が、残念ながらわたしが利用している LuaDNS は存在しなかった。

未対応の DNS プロバイダに対応するためには https://github.com/cert-manager/webhook-example を参考に実装していけばよい。

以前 Traefik on k8s で let's encrypt のワイルドカード TLS 証明書を自動発行する - daily dayflower で書いたように、 Traefik はもともと LuaDNS に対応している。 どのようなコードで対応しているのかな、と思ったが、実際には GitHub - go-acme/lego: Let's Encrypt/ACME client and library written in Go ライブラリを利用しているようだ。

lego の LuaDNS むけ実装cert-manager の webhook example 実装 を照らし合わせてみてみたが、 (DNS-01 チャレンジを実装するという意味で) 似たような構造になっている。

これならそこまで難しくなさそうだぞと思い、 cert-manager webhookLuaDNS 向け実装を書き始めた……

が……

気づいてしまった。

LuaDNS 専用に書くより https://github.com/go-acme/lego のサポートしている DNS プロバイダを汎用的にサポートすることができるんじゃないか?

そもそもそういう実装がすでにあるんじゃないか?

と、思い、探してみました。すでにありました。

github.com

これで自力で開発する必要はなくなった (!) ので、ふつうにこれを使わせてもらうことにする。

(実際には ArgoCD での利用にちょっと問題があったので (あくまで Helm chart 部分だけだけど) fork してそれを使っている: https://github.com/yxwuxuanl/cert-manager-lego-webhook)

といっても、使い方は難しくはなく、 README にあるとおりにやればよい。

まず webhook を Helm でインストールする。

自分はいまは k8s cluster として MicroK8s を使っており、 cert-manager を addon として容易に追加できる。 この場合、 Helm install するときに指定する value としては certManager.namespacecert-manager、および certManager.serviceAccountNamecert-manager となる。

次に Issuer リソース を登録する (以下の例では ClusterIssuer を使っている)。

---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: luadns-issuer
spec:
  acme:
    privateKeySecretRef:
      name: luadns-issuer
    server: https://acme-v02.api.letsencrypt.org/directory
    email: EMAIL-ADDRESS
    solvers:
      - dns01:
          webhook:
            groupName: lego.dns-solver
            solverName: lego-solver
            config:
              provider: luadns
              envFrom:
                secret:
                  namespace: LUADNS-SECRET-NAMESPACE
                  name: LUADNS-SECRET

metadata.name (および spec.acme.privateKeySecretRef.name) は自由につけれる。

それ以外の部分は (おそらく) lego の LuaDNS プロバイダを利用するなら上記のような設定になると思う。

あとは一般的な cert-manager での証明書リソースの登録をすればよい。

---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  namespace: default
  name: example.com
spec:
  issuerRef:
    kind: ClusterIssuer
    name: luadns-issuer
  secretName: example.com-tls
  commonName: example.com
  dnsNames:
    - example.com
    - "*.example.com"

spec.issuerRef に上記で登録した Issuer を指定する。

SOPS と age で暗号化されたファイルを Spring Boot から読み込む

SOPSage が何をするものなのか、はここでは説明しない。

また、公式ドキュメントを読み込んで理解したというより、手探りでやってみたことから推測しているだけなので、もし間違いがあればご指摘ください。

この文書、および サンプルプロジェクト ともに暗号化文書としては YAML のみ取り扱っているが、 JSON においてもほぼ同様に対応できると思われる。

github.com

SOPS で暗号化されたファイルの構造

サンプルファイルとして vault.yaml を挙げる。

暗号文の構造

暗号化された文の部分の構造はわりと自明で、

password: ENC[AES256_GCM,data:PSyc......Ofw=,tag:oMXi5h7kOSOx3oGQVqm67A==,type:str]

のようになっており、以下の情報が詰め込まれている。

  • AES256_GCM - 暗号化アルゴリズムは AES (鍵長 256 bit) の GCM モードである
  • data は暗号文
  • iv は IV (初期化ベクトル)
  • tagMAC (Message Authentication Code)
    • GCM モードに独特
  • type は元の値の型
    • ここでは str (文字列) であることを示しており、ほかに bool, int, float などがある

暗号文の AAD

GCM モードの場合、暗号化の際 AAD (追加認証データ) を指定することができ、整合性チェックに利用できる。

SOPS で暗号化された値には AAD が付与されており、復号時に AAD をきちんと指定しないと不整合エラーとなってしまう。

SOPS における暗号文の AAD はちょっと独特である。

基本的には tree の path を : でつないで、末尾に : を付与した形式となる。 たとえば上記の password フィールドの場合、 "password:" である。

ただし、配列の場合は配列の添え字 (数値) については無視する。

具体的には、

root: secret
first-level:
  second-level:
    third-level: secret
    arrays:
      - secret
      - secret
    objects:
      - key: secret
         value: secret
      - key: secret
         value: secret

のような場合、 AAD はそれぞれ

  • root の secret → root:
  • first-level.second-level.third-level の secret → first-level:second-level:third-level:
  • first-level.second-level.arrays[0] の secret → first-level:second-level:arrays:
  • first-level.second-level.arrays[1] の secret → first-level:second-level:arrays:
  • first-level.second-level.objects[0].key の secret → first-level:second-level:objects:key:
  • first-level.second-level.objects[1].value の secret → first-level:second-level:objects:value:

のようになる。

暗号化鍵

暗号文は age のキーで直接暗号化されている、わけではない。

当該ファイルで共通に利用される DEK (Data Encryption Key) で暗号化されており、おのおのの (age) key を KEK (Key Encryption Key) として暗号化した DEK が SOPS ファイルに記録されている。

このように DEK と KEK を分離することで、異なる (age) key をもつ人々の間で暗号文のやりとりができるし、また age に限らず多様な鍵プロバイダの利用者ともやりとりができることになる。

さらに age key を変更することなく DEK のキーローテーションも可能となっている。

age に限った話をすると、age公開鍵暗号方式を利用している。 KEK としては公開鍵のほうを利用するので、共同利用者として登録してもらうためには公開鍵だけわたせばよい (公開鍵だけでは DEK の復号は不可能)。

age の場合、 sops.ageKEK (の公開鍵) と、それによって暗号化された DEK が記録されている。

sops:
    age:
        - recipient: age10v42a8geamzvv6c0p6aakw2s5u24vhwl3uhged05fepxgfgewytq7eh98m
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBISWs4R1dZelJLeTNxMkRp
            WlhTa20wM3h0NTV1LzRzaWZNangxMW0vN1Q4CllMYWZaYjBOTjlLNytFN2dMdTBH
            V25lZURHK3JSMlpNNzRHZy94ajl6dVUKLS0tIGg1RXRLcG5ETVNEWi9zYTMzQWVF
            a0ZFd2VjZ2VrYlhrTTBZc1NxZWVEWXcKZGeB3UxdzFSmgRk68DiZ+i3Miw3sA5Vt
            Z9olYHBY3tZ98o1h/yzD1vMQUAK8bEPH3n7xAnEv2EnHT9WpRUsWgw==
            -----END AGE ENCRYPTED FILE-----

この場合は一件しか登録されていないが、 sops.age.[].recipientKEK (age の公開鍵) であり、 sops.age.[].enc が、それによって暗号化された DEK である。

(上記の構造からわかるとおり、先にあげたように、複数の recipients を指定することが可能である)

したがって、対応する age秘密鍵を保持している場合、 age コマンドを使うことで (生の) DEK を導出することができる。

$ age --decrypt -i keys.txt -o dek.bin dek.enc

暗号化対象プロパティ

sops:
    version: 3.9.4
    unencrypted_suffix: _unencrypted

sops.version はいうまでもなく SOPS 暗号ファイルのバージョンを示す。

sops.unencrypted_suffix は、元文書で暗号化の対象外となる "キー" のうち、暗号化の対象としないキーを示す。 sops コマンド実行時になにも指定しなかった場合、 _unencryptedunencrypted_suffix となる。

具体的には

foo: encrypted
bar:
  key: encrypted
  value_unencrypted: plain
baz_unencrypted:
  key: plain
  value_unencrypted: plain  

のように、 *_unencrypted となっているノードの子孫についても暗号化の対象外となる。

unencrypted_suffix だけではなく、以下の4つの指定子が存在する。

  • unencrypted_suffix
  • encrypted_suffix
  • unencrypted_regex
  • encrypted_regex

詳細については 公式ドキュメントの Encrypting only parts of a file 項 を参照してほしい。

全体の MAC

sops:
    lastmodified: "2025-02-16T06:24:31Z"
    mac: ENC[AES256_GCM,data:fAu8......yw==,type:str]

sops.lastmodified はファイルの最終更新日時を示す。

sops.mac はファイル全体の MAC (Message Authentication Code) を示す。 これにより、ファイルの一部の書き換え・削除や暗号文のおきかえ・コピー (たとえば foo.bar の暗号文を hoge.fuga にコピーする) といった改ざんを検出できる。

具体的には、各ノードの値を連結して DEK で暗号化した結果……だったと思うが、コードを読んだのがだいぶ前なので忘れてしまいました。

sops コマンドによる暗号化の際に --mac-only-encrypted オプションを付与すると、 (暗号化対象外も含めた) 全ノードではなく、暗号化対象ノードのみとなる、らしい。

くわしくは 公式ドキュメントの Message Authentication Code 項 を参照のこと。

ちなみに sops.mac の AAD (追加認証データ) は sops:mac: ではなく、上記の sops.lastmodified の値となる。

Spring Boot で SOPS (+age) 暗号文書を読み込む

JavaScript / TypeScript の場合 SOPS 暗号文書を読み込むライブラリがあるようだが (sops-age npm module, GitHub repository) JVM 言語用ライブラリは (自力で読み込むものが) ぱっと見当たらなかったので、上記の解析結果をもとに自力で書いてみた。

github.com

復号方法

age による復号 (および暗号化) については jagged というすばらしいライブラリがあるので、それを利用した。

SOPS (というより AES256-GCM) の復号については、 Java 標準の javax.crypto を利用している。

javax.crypto の場合、 cipher.update() に暗号化文と tag の双方をあわせて入れる必要がある。

application properties としての読み込み

Spring アプリケーションから properties として読み込むためには、まず java.org.springframework.core.env.PropertySource (@PropertySource とは別物) を定義する。 (SopsVaultPropertySource.kt)

この独自 PropertySource で SOPS 暗号文書を復号して(SopsVault.kt) properties を返すことにする。 (どうでもよいが、この springframework.core.env.PropertySource が型パラメータを必要とすることが納得いかない)

アプリケーション起動時に自動的に読み込まれるようにするためには、さらに、

手順を踏むことがプラガブルにする上でまっとうなやりかただと思う (くわしくは SpringBoot 標準の RandomValuePropertySource や、その EnvironmentPostProcessor 指定 が参考になる)。

ただこの方法だと PropertySource を configurable にするのがいささか難しいので (起動時に設定したいので、 configuration として application.properties を原則利用できない)、 (Spring Boot ではなく) Spring FrameworkApplicationContextInitializer<ConfigurableApplicationContext> を利用することにした。

github.com

具体的には ApplicationContextInitializerinitialize()environmentpropertySources (の先頭) に追加している。

これにより、プログラマティカルに設定をしやすくなったと思う。

ただ、 Spring Boot の application main で以下のように initializer を指定する必要がある。

    SpringApplicationBuilder(ExampleApplication::class.java)
        .initializers(SopsVaultApplicationContextInitializer())
        .run(*args)

任意の SOPS 暗号文書を application properties の元となる PropertySource として読み込むこともできるはずだが、 IntelliJ (Ultimate) の properties の補完等がうまくいかない可能性が高い。

このため、サンプルプロジェクトでは vault.* として SOPS 暗号文書を読み込み、実際の properties としてはその値を Property Placeholder に読み込むような利用方法としている。

app:
  password: ${vault.password}

(https://github.com/dayflower/sops-vault-example/blob/main/src/main/resources/application.yml#L3-L4)

placeholder は設定値の一部でも問題ないので、たとえばクレデンシャルが URI 等の一部に入っているケースでも活用できると思う。

おわりに

がんばって Spring Boot で直接読み込むサンプルを書いてみたが、コンテナ時代ではここまでやらなくても

  • initContainers で sops コマンドを利用して復号する
  • クレデンシャルのみ Secret にいれる
  • application properties 全体を Secret にいれる

などの方法で十分だと思う。

Traefik on k8s で let's encrypt のワイルドカード TLS 証明書を自動発行する

Traefik (proxy)k8s Ingress controller として使うと、ワイルドカード証明書の自動発行が簡単そうなのでやってみた。 Traefik 公式ドキュメント が、わかりにくい、というか、設定のための情報が散逸してたり、そもそもプロダクトとして必ずしも k8s を前提としているわけではないのでじゃあ k8s 向けにはどうすんだ、みたいなのが難しかったので、やったことのメモ書き。

状況設定としては

  • LAN 内に 192.168.0.100 を IP としてもつサーバがあり、そこで k0s をシングルノードで動かしている
  • example.com という domain を保持している
  • LAN 内の別の PC から、サーバに対して https://nantoka.wildcard.example.com/ でアクセスしたい

みたいな感じ。

インストール

公式 Docker image を使って自力で manifest 書いてもなんとか動くとは思うんだけど、 CRD (Custom Resource Definitions) とかあるしけっこう骨だと思われるので、素直に公式の Helm chart を使ってインストールする。

基本的には公式ドキュメントの k8s 向けインストール手順に従えばいい。

公式レポジトリを

$ helm repo add traefik https://helm.traefik.io/traefik

追加し

$ helm inspect values traefik/traefik > values.yaml

のようにして設定ファイルを作成しておく。

設定を指定しつインストール

$ helm install -f values.yaml traefik traefik/traefik

インストール後に設定ファイル values.yaml を更新してそれを反映するときは

$ helm upgrade -f values.yaml traefik traefik/traefik

とする。

service.externalIPs を指定して外部からのアクセスをうけつける

これで Ingress Controller としては立ち上がったので通常の Ingress を作成するとクラスタ外部からアクセスできるようになる、はず、だが、 公式 Helm chart でインストールするとデフォルトでは Traefik の Service が外部に露出していない。

さきほど作成した values.yamlservice.externalIPs に露出する IP を指定する。

service:
  externalIPs:
    - 192.168.0.100

これでクラスタ外から Ingress / Service 経由で Pod 等にアクセスできるようになる。

Traefik の dashboard にアクセスする

https://doc.traefik.io/traefik/getting-started/install-traefik/#exposing-the-traefik-dashboard

いったんはこれの port-forward で dashboard にアクセスする。

Helm chart でインストールすると dashboard 用の Traefik IngressRoute がインストールされてはいるが、 entrypoint が traefik になっており、これは Helm chart でセットアップされる port としては expose: false になっているためそのままでは外部からアクセスできない。

このへんは TLS まわりのセットアップがおわってから整備していくことにする。

DNS の設定

LAN 内の別の PC から、サーバに対して https://nantoka.wildcard.example.com/ でアクセスしたい

なので、 DNS サーバで *.wildcard.example.com の A レコードを (今回の例だと) 192.168.0.100 に設定する。

Persistent Volume の設定

Traefik は Let's Encrypt (および ACME protocol サポートしている TLS 証明書発行者) の証明書の自動発行・自動更新に対応していると冒頭に書いたが、 そのためには Traefik サーバ側で発行された証明書を管理するために永続ストレージが必要となる。

k8s クラスタで PersistentVolumeClaim (PVC) に対応した PersistentVolume があるのであれば、 values.yaml

persistence:
  enabled: true

のように書くと、 PersistentVolume が /data というパス (実際には values.yamlpersistence.path で指定されている) にマウントされる。 ( traefik-helm-chart/values.yaml at 5d97a2e30076302950c31fc9a98f267bdd624fe8 · traefik/traefik-helm-chart · GitHub 参照)

hostPath Volume を利用する場合

自分の環境の場合、シングルノードでもありまだちゃんとした PersistentVolume はセットアップしていなかったので、いったん hostPath な Volume を利用することにした。

この場合、以下のような values.yaml を書くことになる。

deployment:
  additionalVolumes:
    - name: acmeStore
      hostPath:
        path: /volumes/acmeStore
        type: Directory

additionalVolumeMounts:
  - name: acmeStore
    mountPath: /acmeStore

deployment.additionalVolumes[].hostPath.path は適宜 k8s ノード側のパスを指定すること (この例だと /volumes/acmeStore を用意した)。 公式の Helm chart だと /data ディレクトリをマウント先としているため、そことかぶらないようにした。

(ちなみにこのへんの設定は、結局 traefik-helm-chart/_podtemplate.tpl at master · traefik/traefik-helm-chart · GitHub などのファイルを読み解いた。このへんも公式 Helm chart のわかりづらいところだと思う)

また、 公式 Helm chart だと、プロセスが uid=65532, gid=65532 で動くので、そのアカウントにとって writable なディレクトリにしておく必要がある。

$ sudo chown 65532:65532 /volumes/acmeStore

証明書ストアの場所を Traefik に設定する

証明書ストアの場所だが https://doc.traefik.io/traefik/https/acme/#storage に書いてあるように、 Traefik の設定としては、たとえば以下のように書く必要がある。

certificatesResolvers:
  myresolver:
    acme:
      storage: /acmeStore/acme.json

が、これはあくまで Traefik の static configuration に書いておく必要があるのであって、これをそのまま values.yaml に書くのではない (わかりづらい)。

残念ながら現在の公式 Helm chart では certficicatesResolvers の便利な書き方がサポートされているわけではないので、以下のように書く必要がある。

additionalArguments:
  - "--certificatesResolvers.le.acme.storage=/acmeStore/acme.json"

certificatesResolver の名前としては、公式ドキュメントに倣って le (Let's Encrypt の略かな?) としたが、なんでも構わない。

ACME Challenge の設定

Let's Encrypt でワイルドカード証明書を取得するためには、 DNS-01 Challenge を利用する必要がある。

https://doc.traefik.io/traefik/https/acme/#dnschallenge

かんたんにいうと、 対象となるドメインの TXT レコードを設定し Let's Encrypt 側にそれを確認してもらう Challenge である。

なので HTTP-01 Challenge や TLS-ALPN-01 Challenge と異なり、今回のように対象ドメインの解決先が private IP でも利用できる (副産物であるが)。

Challenge の設定をするためには、 Traefik の設定として下記のように設定する。

certificatesResolvers:
  myresolver:
    acme:
      storage: /acmeStore/acme.json  # 設定済
      email: [email protected]
      dnsChallenge:
        provider: ***provider***

さきほど説明したように、これはあくまで Traefik の設定なので、 Helm chart でインストールしている場合は以下のように additionalArguments で設定する必要がある。

additionalArguments:
  - "--certificatesResolvers.le.acme.storage=/acmeStore/acme.json"  # 設定済
  - "[email protected]"
  - "--certificatesResolvers.le.acme.dnsChallenge:provider=***provider***"

さて、上記の例で ***provider*** と書いてあるところは、 DNS プロバイダを指定する。 どのような DNS プロバイダがサポートされているかは、以下に記載されている (ライブラリとして LEGO を利用しているようだ)。

https://doc.traefik.io/traefik/https/acme/#providers

今回自分は LuaDNS を利用したが、もちろん Route 53 や Google Cloud DNS (Google Domains の DNS ではないことに注意 *1 ) もサポートされている、だけではなくさくらのクラウドIIJ もサポートされているようだ。 また、 仮にサポートされていないとしても外部プログラムを用いて TXT レコードを設定することができれば利用できそうである。

LuaDNS の場合、

additionalArguments:
  - "--certificatesResolvers.le.acme.dnsChallenge:provider=luadns"

のように書く。

また、 LuaDNS 用の設定として環境変数を指定する必要がある。 環境変数values.yml に以下のように記述する。

env:
  - name: LUADNS_API_USERNAME
    value: "***username***"   # 実質 LuaDNS でのアカウントの e-mail アドレス
  - name: LUADNS_API_TOKEN
    value: "***API token***"

アプリケーションの公開

ということで、ようやくアプリケーションを LAN 内に公開できるようになった。

なんとなく定番っぽい whoami イメージを立ち上げることにする。

Deployment と Service については、特筆するべきことはないと思う。

Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: whoami-deployment
spec:
  selector:
    matchLabels:
      app: whoami
  replicas: 1
  template:
    metadata:
      labels:
        app: whoami
    spec:
      containers:
      - name: whoami
        image: jwilder/whoami
        ports:
        - containerPort: 8000

Service

apiVersion: v1
kind: Service
metadata:
  name: whoami-service
  labels:
    app: whoami
spec:
  selector:
    app: whoami
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8000

Ingress

TLS 証明書自動発行のためには、 通常 Ingress として利用される networking.k8s.io/v1Ingress ではなく Traefik の CustomResource である traefik.containo.us/v1alpha1 の IngressRoute を利用する必要がある。

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: whoami-ingress
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`whoami.wildcard.example.com`)
      kind: Rule
      services:
        - name: whoami-service
          port: 80
  tls:
    certResolver: le
    domains:
      - main: "wildcard.example.com"
        sans:
          - "*.wildcard.example.com"

spec.tls.certResolver のところで、上記で設定した certificatesResolvers である le を指定している。

また、 domains のところで指定したとおり、

  • wildcard.example.com をメインの domain としつつ
  • SANs (サブジェクト代替名) としてワイルドカード*.wildcard.example.com を含む

TLS 証明書が Let's encrypt から発行される。

ここは main として *.wildcard.example.com を指定しても (ドキュメントによれば) うまくいくと思うが、 ドキュメントのサンプル設定にしたがってこのようにした。

この IngressRoute resource により、クライアントから https://whoami.wildcard.example.com/ にアクセスすると

  • まだ TLS 証明書を発行していなければ発行する
  • すでに発行されているが古ければ再発行する
  • さもなければすでに発行されている証明書を利用する

といった動作となる。

Traefik dashboard を LAN からアクセスできるようにする

これで任意の *.wildcard.example.com に対して TLS アクセスができるようになった。

さきほどまで port-forward で利用していた Traefik dashboard も IngressRoute で公開してみる。

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: dashboard-traefik
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`traefik.wildcard.example.com`) && (PathPrefix(`/dashboard`) || PathPrefix(`/api`))
      kind: Rule
      services:
        - name: api@internal
          kind: TraefikService
  tls:
    certResolver: le
    domains:
      - main: "wildcard.example.com"
        sans:
          - "*.wildcard.example.com"

このリソースを作成することで、 https://traefik.wildcard.example.com/dashboard/ にアクセスすると dashboard にアクセスできるようになる。

すこしだけ IngressRoute の TLS 設定を簡略化する

毎回毎回証明書の main と SANs を指定していくのはダルいし、もしかすると別のリソースで間違った SANs を指定してしまうかもしれない。

entryPoints の static configuration として TLS 設定をしておくと、各 IngressRoute のほうの設定が少し楽になる。

values.ymlports: に entryPoint の設定があるので、

ports:
  websecure:
    port: 8443
    expose: true
    exposedPort: 443
    protocol: TCP
    tls:
      enabled: false
      options: ""
      certResolver: ""
      domains: []

となっているところを

ports:
  websecure:
    tls:
      enabled: false
      options: ""
      certResolver: le
      domains:
        - main: "wildcard.example.com"
          sans:
            - "*.wildcard.example.com"

のようにする。

ports.websecure.tls.enabled は false のままでよいと思う。 もしかすると k8s クラスタで利用する TLS 証明書が単一の場合はここを true にしておくことで、 IngressRoute 側で一切指定をしなくても TLS 証明書つきアクセスを提供できるのかもしれない。 https://doc.traefik.io/traefik/user-guides/crd-acme/#traefik-routers を参照したところ spec.tls.certResolver の設定だけやってますね。 時間ができたらやってみる。

これで IngressRoute のほうは

spec:
  tls:
    certResolver: le
    domains:
      - main: "wildcard.example.com"

のように指定するだけで、ワイルドカード証明書を利用できるようになった。

まあ一行減っただけだし、そもそも main としてワイルドカードドメインを指定していれば減るわけでもないので、ふつうはここまでやる必要はないのかもしれない。

*1:Google Domains の DNS は TXT レコードの API 更新が存在しないため現在はサポートされていない

react-scripts の dev server を fastify(-webpack-hmr) で動かす

とりあえずやってみたらパッと見動いた、くらいの内容で、ただのメモ書きです。

API server と create-react-apps で生成した Vue app を dev server で同居させるには、 本来的には dev server からの proxy でなんとかする するのがスジっぽい。

でも、たとえば本番は build した内容を static contents として (API server と同居させて) serve するのに、開発環境は proxy する (しかも dev server のほうが表に立つ) のはどうなんだという気がしなくもない。

Next.js なら GitHub - fastify/fastify-nextjs: React server side rendering support for Fastify with Next を使えば fastify から serve できるっぽいんだけど (これが hot reloading に対応しているかは不明)、 create-react-app (が利用する react-scripts dev server) ではどうすればいいのかわからない。

なので、 なんとかならんかなーと思ってやってみた。

create-react-app/start.js at main · facebook/create-react-app · GitHub をコピペしつつ GitHub - lependu/fastify-webpack-hmr: Webpack hot module reloading for Fastify をくみこんでみただけです。

react-scripts の dev server、デフォルトで react-dev-utils の create-react-app/webpackHotDevClient.js at main · facebook/create-react-app · GitHub を利用してるっぽいんだけど、それを利用するのはうまくいかなかった。

なのでこれだとエラー画面とかでないんじゃないかな…… (2021-09-17 追記: ビルドエラーはちゃんとでました)

TypeScript で書きたかったんだけど、 型定義ないやつあったり @types が古かったりしたんで、とりあえず (もとの JS のまま) コピペ。

process.env.BABEL_ENV = 'development';
process.env.NODE_ENV = 'development';

const fs = require('fs');
const {
  choosePort,
  createCompiler,
  prepareUrls,
} = require('react-dev-utils/WebpackDevServerUtils');
const paths = require('react-scripts/config/paths');
const configFactory = require('react-scripts/config/webpack.config');
const webpack = require('webpack');
const fastify = require('fastify');
const hmr = require('fastify-webpack-hmr');

async function main() {
  const useYarn = fs.existsSync(paths.yarnLockFile);

  const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000;
  const HOST = process.env.HOST || '0.0.0.0';

  const port = await choosePort(HOST, DEFAULT_PORT);
  if (port === null) {
    return;
  }

  const config = configFactory('development');
  const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
  const appName = require(paths.appPackageJson).name;

  const useTypeScript = fs.existsSync(paths.appTsConfig);
  const urls = prepareUrls(
    protocol,
    HOST,
    port,
    paths.publicUrlOrPath.slice(0, -1)
  );

  config.entry = [
    'webpack-hot-middleware/client?reload=true&timeout=1000',
    config.entry,
  ];
  config.plugins.push(new webpack.HotModuleReplacementPlugin());

  const compiler = createCompiler({
    appName,
    config,
    urls,
    useYarn,
    useTypeScript,
    webpack,
  });

  const server = fastify({
    logger: true,
  });

  server.register(hmr, { compiler, webpackDev: {}, webpackHot: {} });

  server.listen(port, HOST);
}

main();

2021-09-17 追記

React Router: Declarative Routing for React.js はうまくいかなかった (/ 以外の route に遷移して reload すると 404 になってしまう)。 シンプルに解決するのは難しいっぽい (Support for React Router Routes · Issue #8 · lependu/fastify-webpack-hmr · GitHub) ので、

  • どうせ dev server なのであきらめる
    • 別段これでいいと思う
  • HashRouter を使う

しかなさそう。

pug でカスタムタグをあつかう

ゴール

form-button(label="Label")

のような入力を与えたときに

<button type="button">Label</button>

のような出力を得る。

pug の処理の流れ

おおまかにいうと、 字句解析 → 構文解析 → コード生成、の順をおって最終成果物 (html) が生成されている。 各フェーズは npm module (pug のソースコード的には packages ディレクトリ) に分割されており、個別の処理を追うのはそこまで難しくない。また、全体の処理は pug/lib/index.js を読むと流れがわかる。

pug-lexer (字句解析器)

以下、下記の pug ソースを処理していくこととする。

div.container#main(style="margin: 2rem;")
  h1 Heading
  //- comment
  | Hello, #{ world + '?' }!

まずは pug-lexer による字句解析フェーズ。

const lex = require('pug-lexer');

const source = `
div.container#main(style="margin: 2rem;")
  h1 Heading
  //- comment
  | Hello, #{ world + '?' }!
`;

console.log(JSON.stringify(lex(source), null, '  '));

この出力は以下のような感じ (長過ぎるので抜粋)。

[
  {
    "type": "newline",
    "loc": {
      "start": {
        "line": 2,
        "column": 1
      },
      "end": {
        "line": 2,
        "column": 1
      }
    }
  },
  {
    "type": "tag",
    "loc": {
      "start": {
        "line": 2,
        "column": 1
      },
      "end": {
        "line": 2,
        "column": 4
      }
    },
    "val": "div"
  },
  // ...
]

pug-strip-comment (コメントの削除)

pug-strip-comment によってコード中のコメントを削除する。

たいした内容ではないので、このフェーズのコードは省略する。

pug-parser (構文解析器)

lexer により解析された token の構文解析をおこなうのが pug-parser である。

const lex = require('pug-lexer');
const stripComments = require('pug-strip-comments');
const parse = require('pug-parser');

const source = `...`;

console.log(JSON.stringify(parse(stripComments(lex(source))), null, '  '));

少し長くなるが、結果を掲出する。

{
  "type": "Block",
  "nodes": [
    {
      "type": "Tag",
      "name": "div",
      "selfClosing": false,
      "block": {
        "type": "Block",
        "nodes": [
          {
            "type": "Tag",
            "name": "h1",
            "selfClosing": false,
            "block": {
              "type": "Block",
              "nodes": [
                {
                  "type": "Text",
                  "val": "Heading",
                  "line": 3,
                  "column": 6
                }
              ],
              "line": 3
            },
            "attrs": [],
            "attributeBlocks": [],
            "isInline": false,
            "line": 3,
            "column": 3
          },
          {
            "type": "Text",
            "val": "Hello, ",
            "line": 5,
            "column": 5
          },
          {
            "type": "Code",
            "val": " world + '?' ",
            "buffer": true,
            "mustEscape": true,
            "isInline": true,
            "line": 5,
            "column": 12
          },
          {
            "type": "Text",
            "val": "!",
            "line": 5,
            "column": 28
          }
        ],
        "line": 2
      },
      "attrs": [
        {
          "name": "class",
          "val": "'container'",
          "line": 2,
          "column": 4,
          "mustEscape": false
        },
        {
          "name": "id",
          "val": "'main'",
          "line": 2,
          "column": 14,
          "mustEscape": false
        },
        {
          "name": "style",
          "val": "\"margin: 2rem;\"",
          "line": 2,
          "column": 20,
          "mustEscape": true
        }
      ],
      "attributeBlocks": [],
      "isInline": false,
      "line": 2,
      "column": 1
    }
  ],
  "line": 0
}

これが pug における、いわゆる AST (抽象構文木) となる。

pug-load (ローダ)

字句解析からスタートしたが、 pug には includesextends といった、他のファイルを参照するしくみがある。

これを実現するため、実際の pug では、ファイル読み込みを担う pug-load から lexer や parser を呼び出す形になっている。

(もちろん、 pug-load を利用せずに、これまでみてきたように lexer や parser を直接呼び出すやりかたでも正常に動作する)

pug-link (最適化)

pug-load により、外部ファイルを参照して include したり extend したりすることができるようになっているわけであるが、その参照先を実際に埋め込んだり flat 化したりするのが pug-link 、らしい。 しかしちゃんと調べていない。

pug-code-gen (コード生成)

AST をもとに、 JavaScript コードを生成するのが pug-code-gen である。

const lex = require('pug-lexer');
const stripComments = require('pug-strip-comments');
const parse = require('pug-parser');
const generateCode = require('pug-code-gen');

const source = `...`;

const code = generateCode(parse(stripComments(lex(source))), {
  pretty: true,
  compileDebug: false,
});
console.log(code);

実行結果は以下のようになる。

function template(locals) {var pug_html = "", pug_mixins = {}, pug_interp;;var locals_for_with = (locals || {});(function (world) {var pug_indent = [];
pug_html = pug_html + "\n\u003Cdiv class=\"container\" id=\"main\" style=\"margin: 2rem;\"\u003E\n  \u003Ch1\u003EHeading\u003C\u002Fh1\u003EHello, " + (pug.escape(null == (pug_interp = world + '?') ? "" : pug_interp)) + "!\n\u003C\u002Fdiv\u003E";}.call(this,"world" in locals_for_with?locals_for_with.world:typeof world!=="undefined"?world:undefined));;return pug_html;}

関数オブジェクトが戻るわけではなく、 JavaScriptソースコードが文字列形式で戻ることに注意。

見づらいので整形すると、以下のとおり。

function template(locals) {
  let pug_html = '';
  const pug_mixins = {};
  let pug_interp;
  const locals_for_with = locals || {};
  (function (world) {
    const pug_indent = [];
    pug_html = `${pug_html}\n\u003Cdiv class="container" id="main" style="margin: 2rem;"\u003E\n  \u003Ch1\u003EHeading\u003C\u002Fh1\u003EHello, ${pug.escape(
      (pug_interp = `${world}?`) == null ? '' : pug_interp
    )}!\n\u003C\u002Fdiv\u003E`;
  }.call(
    this,
    'world' in locals_for_with
      ? locals_for_with.world
      : typeof world !== 'undefined'
      ? world
      : undefined
  ));
  return pug_html;
}

レンダリング

ブラウザ上でレンダリングする場合、単純に上記で得られた JavaScript コードを実行すればレンダリングできる。

const compiled = new Function('', `${code};return template;`);

console.log(compiled());

Node.js 上で実行する場合、ブラウザでは用意されている関数等が足りていないので pug-runtime によるラッパーを利用してコードを生成する必要がある。

const runtimeWrap = require('pug-runtime/wrap');

const compiled = runtimeWrap(code);

console.log(compiled());

実行結果は (code-gen で pretty: true を指定したため) 以下のようになる。

<div class="container" id="main" style="margin: 2rem;">
  <h1>Heading</h1>Hello, undefined?!
</div>

カスタムタグの実装

ゴールで明示したとおり、

form-button(label="Label")

のようなカスタムタグ (form-button) を記述したときに

<button type="button">Label</button>

のような出力を得たい。

そのためになにをやればよいかというと、 AST を解析して、 form-button というタグが指定された場合に、 <button> タグとして出力される AST node 群で差し替えればよい。

差し替えするノードの算出

これまで見てきたのと同じしくみをもちいて、差し替え後の AST を取得する。

const lex = require('pug-lexer');
const stripComments = require('pug-strip-comments');
const parse = require('pug-parser');

const macro = `
button(type="button") {{label}}
`;

console.log(JSON.stringify(parse(stripComments(lex(macro))), null, '  '));

結果は、

{
  "type": "Block",
  "nodes": [
    {
      "type": "Tag",
      "name": "button",
      "selfClosing": false,
      "block": {
        "type": "Block",
        "nodes": [
          {
            "type": "Text",
            "val": "{{label}}",
            "line": 2,
            "column": 23
          }
        ],
        "line": 2
      },
      "attrs": [
        {
          "name": "type",
          "val": "\"button\"",
          "line": 2,
          "column": 8,
          "mustEscape": true
        }
      ],
      "attributeBlocks": [],
      "isInline": false,
      "line": 2,
      "column": 1
    }
  ],
  "line": 0
}

差し替えするときには、大外の Block ノードは不要なので、下位の nodes の先頭を用いればよい。

したがって、引数 label を与えられたときに、変換後の node を返す関数は以下のようになる。

function renderMacro(label) {
  return {
    type: 'Tag',
    name: 'button',
    selfClosing: false,
    block: {
      type: 'Block',
      nodes: [
        {
          type: 'Text',
          val: label,
        },
      ]
    },
    attrs: [
      {
        name: 'type',
        val: '"button"',
        mustEscape: true,
      },
    ],
    attributeBlocks: [],
    isInline: false,
  };
}

pug-walk によるトラバーサル

AST の差し替えは、自力で再帰を利用したりしてトラバーサルするのも手であるが、便利なツールが pug ファミリーに存在する。 それが pug-walk である。

walk 関数に、もとの AST と、ツリーをたどるときに呼ばれる関数をわたして呼び出すと、変換後の AST が返る。といいたいところだが、残念ながら mutable な関数なので、もとの AST 自身も変換される。

pug-walk を利用した変換器は以下のようになる。

const walk = require('pug-walk');

function renderMacro(label) {
  return {
    // 略
  };
}

function stripQuote(src) {
  return src.replace(/^"(.*)"$/, '$1');
}

const source = `
div
  form-button(label="Label")
`;

const ast = walk(parse(stripComments(lex(source))), null, (node, replace) => {
  if (node.name === 'form-button') {
    const targetAttrs = node.attrs.filter(it => {
      return it.name === 'label';
    });
    const label = targetAttrs.length > 0 ? stripQuote(targetAttrs[0].val) : 'LABEL';
    replace(renderMacro(label));
  }
});

console.log(runtimeWrap(generateCode(ast, { pretty:true }))());

結果は、

<div>
  <button type="button">Label</button>
</div>

無事ゴールが達成できた。

pug の plugin

以上のように、 pug-parser や pug-code-gen を自力で呼び出して AST を変換すればカスタムタグを実装することができるが、実は pug には plugin system があり、これを利用することで、 pug の処理の途中に介入することができる。

pug の plugin system については、なぜか公式ドキュメントで言及されていない気がするが、 pug の compileBody() メソッド を読むとその挙動 (仕様) がわかる。

だいたい以下のような処理を経るようだ。

  • preLex plugin (引数: source string)
  • lex phase
  • postLex plugin (引数: tokens)
  • stripComments phase
  • preParse plugin (引数: tokens)
  • parse phase
  • postParse plugin (引数: ast)
  • preLoad plugin (引数: ast)
  • (load 処理)
  • postLoad plugin (引数: ast)
  • preFilters plugin (引数: ast)
  • handleFilters phase
  • postFilters plugin (引数: ast)
  • preLink plugin (引数: ast)
  • link phase
  • postLink plugin (引数: ast)
  • preCodeGen plugin (引数: ast)
  • generateCode phase
  • postCodeGen plugin (引数: JavaScript source string)
  • execute phase

pug plugin として実装する

上記のように ast をさわれる plugin phase はいくつかあるのだが、今回のカスタムタグについては、とりあえず preCodeGen phase にしかけることにした。

const pug = require('pug');
const walk = require('pug-walk');

function renderMacro(label) {
  return {
    // 略
  };
}

function stripQuote(src) {
  return src.replace(/^"(.*)"$/, '$1');
}

const source = `
div
  form-button(label="Label")
`;

console.log(
  pug.render(source, {
    plugins: [
      {
        preCodeGen: (ast, options) => {
          return walk(ast, null, (node, replace) => {
            if (node.name === 'form-button') {
              const targetAttrs = node.attrs.filter(it => {
                return it.name === 'label';
              });
              const label = targetAttrs.length > 0 ? stripQuote(targetAttrs[0].val) : 'LABEL';
              replace(renderMacro(label));
            }
          });
        },
      },
    ],
  })
);

これで自力で lexer や parser をよびだすことなく、処理途中で ast に手を加えることができるようになった。