Colorless Green Ideas

インフラエンジニアとして勉強したこと

Ansibleでufwによるファイアウォール構築を試す

要約

  • Ubuntu Server 22.04 LTSに標準でインストールされているファイアウォール管理ツールであるufwを試した。
  • 検証にはAnsibleを用い、community.general.ufwを通して動作を確認した。
  • ポートや送信元/宛先に基づくパケットフィルタくらいのシンプルな用途であれば十分に使えそうに感じられた。
  • community.general.ufwモジュールの各種パラメータにおいて、いくつかのパラメータ間に依存関係があるのでその点は注意が必要。

はじめに

勉強のためにufwを使ってLinuxベースのファイアウォール構築を試しました。

ufwのuは"Uncomplicated"の略であり、その名の通り素のiptablesなどと比べて直感的なインターフェースを提供しています。

★参考:ufw in Launchpad

ufwは、iptablesでは可能な細やかな制御(パケットの特定フィールドの内容に基づくフィルタルール設定、カスタムチェーンの作成など)ができない反面、使いやすさに重きを置いているようです。

調べる前はホスト型ファイアウォール専用という先入観がありましたが、実際はそんなことはなく、後述の通り転送パケットに対してもフィルタの設定は可能です。

ポートや送信元/宛先を指定して許可/拒否するくらいの、アプライアンスを導入するまでもないファイアウォール構築であれば丁度良いツールかもしれません。

検証

Ansibleのcommunity.general.ufwを使い検証しました。

★参考:community.general.ufw module – Manage firewall with UFW — Ansible Documentation

具体的には以下のようなシナリオを想定しています。

  • ネットワーク型ファイアウォールとして構築
  • 当該ファイアウォールは4つのインターフェースを持ち、それぞれglobal、dmz、internal、managementの4つのセグメントに接続されている
  • 各セグメントとネットワークアドレスの対応関係は下記の通り
    • global: 192.168.201.0/24
    • dmz: 192.168.202.0/24
    • internal: 192.168.203.0/24
    • management: 10.0.2.0/24
  • 設定するルールは下記の通り

上記に基づき、下記のようなPlaybookとvarsファイルを作成しました。

Playbook

---
- name: Set Default Deny Policy
  become: true
  community.general.ufw:
    direction: "{{ item }}"
    policy: deny
  loop:
    - incoming
    - routed

- name: Set Allow Rule To Incoming
  become: true
  community.general.ufw:
    rule: allow
    comment: "{{ item.comment }}"
    from_ip: "{{ item.from_ip }}"
    interface_in: "{{ item.if_in }}"
    port: "{{ item.port }}"
  loop: "{{ fw_allow_incoming_list }}"

- name: Set Allow Rule To Routing
  become: true
  community.general.ufw:
    rule: allow
    comment: "{{ item.comment }}"
    route: true
    from_ip: "{{ item.from_ip }}"
    interface_in: "{{ item.if_in }}"
    to_ip: "{{ item.to_ip }}"
    interface_out: "{{ item.if_out }}"
    port: "{{ item.port }}"
  loop: "{{ fw_allow_routing_list }}"

- name: Set UFW ON
  become: true
  community.general.ufw:
    state: enabled

上記のPlaybookについて簡単に説明します。

まず"Set Default Deny Policy"で、ファイアウォール自身への接続および転送パケットのフィルタリングのポリシーをDeny(原則許可しない)にしています。

続けて"Set Allow Rule To Incoming"で、ファイアウォール自身への接続を許可するためのルールを設定しています。

ルールの設定には送信元IPアドレス、入力インタフェース、宛先ポートの3つを指定しています。

また"comment"のパラメータにより、そのルールの意図を記録できるようにしています。

"Set Allow Rule To Routing"で、ファイアウォールのパケットの転送を許可するためのルールを設定しています。

ルールの設定には送信元IPアドレス、入力インタフェース、宛先IPアドレス、出力インタフェース、宛先ポートの5つを指定しています。

最後に"Set UFW ON"により、ufwを有効化しています。

このufwの有効化は、ポリシーをDenyにしてから許可するルールを設定する前に実行してしまうと、対象のファイアウォールへの通信が一切できなくなってしまうため注意が必要です。

このPlaybookに対して、下記のように変数を設定することで、想定シナリオに合ったルールを設定します。

varsファイル

---
fw_allow_incoming_list:
  - comment: "management -> self: SSH"
    from_ip: 10.0.2.0/24
    if_in: enp0s3
    port: 22

fw_allow_routing_list:
  - comment: "global -> dmz: HTTP"
    from_ip: 192.168.201.0/24
    if_in: enp0s8
    to_ip: 192.168.202.0/24
    if_out: enp0s9
    port: 80
  - comment: "global -> dmz: HTTPS"
    from_ip: 192.168.201.0/24
    if_in: enp0s8
    to_ip: 192.168.202.0/24
    if_out: enp0s9
    port: 443
  - comment: "internal -> global: SSH"
    from_ip: 192.168.203.0/24
    if_in: enp0s8
    to_ip: 192.168.201.0/24
    if_out: enp0s9
    port: 22
  - comment: "internal -> global: HTTP"
    from_ip: 192.168.203.0/24
    if_in: enp0s8
    to_ip: 192.168.201.0/24
    if_out: enp0s9
    port: 80
  - comment: "internal -> global: HTTPS"
    from_ip: 192.168.203.0/24
    if_in: enp0s8
    to_ip: 192.168.201.0/24
    if_out: enp0s9
    port: 443
  - comment: "internal -> dmz: SSH"
    from_ip: 192.168.203.0/24
    if_in: enp0s8
    to_ip: 192.168.202.0/24
    if_out: enp0s9
    port: 22
  - comment: "internal -> dmz: HTTP"
    from_ip: 192.168.203.0/24
    if_in: enp0s8
    to_ip: 192.168.202.0/24
    if_out: enp0s9
    port: 80
  - comment: "internal -> dmz: HTTPS"
    from_ip: 192.168.203.0/24
    if_in: enp0s8
    to_ip: 192.168.202.0/24
    if_out: enp0s9
    port: 443

Ansibleの実行結果は下記の通りです。

実行ログ

TASK [fw : Install Require Packages] *******************************************
ok: [extfw0101]

TASK [fw : Set Kernel Parameter] ***********************************************
ok: [extfw0101]

TASK [fw : Set Default Deny Policy] ********************************************
ok: [extfw0101] => (item=incoming)
ok: [extfw0101] => (item=routed)

TASK [fw : Set Allow Rule To Incoming] *****************************************
changed: [extfw0101] => (item={'comment': 'management -> self: SSH', 'from_ip': '10.0.2.0/24', 'if_in': 'enp0s3', 'port': 22})

TASK [fw : Set Allow Rule To Routing] ******************************************
changed: [extfw0101] => (item={'comment': 'global -> dmz: HTTP', 'from_ip': '192.168.201.0/24', 'if_in': 'enp0s8', 'to_ip': '192.168.202.0/24', 'if_out': 'enp0s9', 'port': 80})
changed: [extfw0101] => (item={'comment': 'global -> dmz: HTTPS', 'from_ip': '192.168.201.0/24', 'if_in': 'enp0s8', 'to_ip': '192.168.202.0/24', 'if_out': 'enp0s9', 'port': 443})
changed: [extfw0101] => (item={'comment': 'internal -> global: SSH', 'from_ip': '192.168.203.0/24', 'if_in': 'enp0s8', 'to_ip': '192.168.201.0/24', 'if_out': 'enp0s9', 'port': 22})
changed: [extfw0101] => (item={'comment': 'internal -> global: HTTP', 'from_ip': '192.168.203.0/24', 'if_in': 'enp0s8', 'to_ip': '192.168.201.0/24', 'if_out': 'enp0s9', 'port': 80})
changed: [extfw0101] => (item={'comment': 'internal -> global: HTTPS', 'from_ip': '192.168.203.0/24', 'if_in': 'enp0s8', 'to_ip': '192.168.201.0/24', 'if_out': 'enp0s9', 'port': 443})
changed: [extfw0101] => (item={'comment': 'internal -> dmz: SSH', 'from_ip': '192.168.203.0/24', 'if_in': 'enp0s8', 'to_ip': '192.168.202.0/24', 'if_out': 'enp0s9', 'port': 22})
changed: [extfw0101] => (item={'comment': 'internal -> dmz: HTTP', 'from_ip': '192.168.203.0/24', 'if_in': 'enp0s8', 'to_ip': '192.168.202.0/24', 'if_out': 'enp0s9', 'port': 80})
changed: [extfw0101] => (item={'comment': 'internal -> dmz: HTTPS', 'from_ip': '192.168.203.0/24', 'if_in': 'enp0s8', 'to_ip': '192.168.202.0/24', 'if_out': 'enp0s9', 'port': 443})

TASK [fw : Set UFW ON] *********************************************************
changed: [extfw0101]

PLAY RECAP *********************************************************************
extfw0101                  : ok=7    changed=4    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

対象のファイアウォールのマシンにログインし、ufwのルールのリストを表示させた結果は下記の通りです。

vagrant@extfw0101:~$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22 on enp0s3               ALLOW IN    10.0.2.0/24                # management -> self: SSH

192.168.202.0/24 80 on enp0s9 ALLOW FWD   192.168.201.0/24 on enp0s8 # global -> dmz: HTTP
192.168.202.0/24 443 on enp0s9 ALLOW FWD   192.168.201.0/24 on enp0s8 # global -> dmz: HTTPS
192.168.201.0/24 22 on enp0s9 ALLOW FWD   192.168.203.0/24 on enp0s8 # internal -> global: SSH
192.168.201.0/24 80 on enp0s9 ALLOW FWD   192.168.203.0/24 on enp0s8 # internal -> global: HTTP
192.168.201.0/24 443 on enp0s9 ALLOW FWD   192.168.203.0/24 on enp0s8 # internal -> global: HTTPS
192.168.202.0/24 22 on enp0s9 ALLOW FWD   192.168.203.0/24 on enp0s8 # internal -> dmz: SSH
192.168.202.0/24 80 on enp0s9 ALLOW FWD   192.168.203.0/24 on enp0s8 # internal -> dmz: HTTP
192.168.202.0/24 443 on enp0s9 ALLOW FWD   192.168.203.0/24 on enp0s8 # internal -> dmz: HTTPS

意図した通りにルールがセットされていることが分かります。

所感

上記の通りcommunity.general.ufwでシンプルにファイアウォールの設定ができますが、試した際にいくつか注意するべきと感じたポイントがありました。

パラメータ間の依存関係

公式ドキュメントに記載の通り、複数同時に設定できないパラメータがあります。

"interface"が"interface_in"および"interface_out"と同時に使えない、というのは分かりやすいのですが、"direction"が"interface_in"および"interface_out"と同時に使えない、というのはやや直感的ではないかもしれません。

また"interface_in"と"interface_out"が設定されており、かつ"route"が設定されていない場合には下記のようなエラーが出力されます。

TASK [fw : Set Allow Rule To Routing] ******************************************
failed: [extfw0101] (item={'comment': 'global -> dmz: HTTP', 'from_ip': '192.168.201.0/24', 'if_in': 'enp0s8', 'to_ip': '192.168.202.0/24', 'if_out': 'enp0s9', 'port': 80}) => {"ansible_loop_var": "item", "changed": false, "item": {"comment": "global -> dmz: HTTP", "from_ip": "192.168.201.0/24", "if_in": "enp0s8", "if_out": "enp0s9", "port": 80, "to_ip": "192.168.202.0/24"}, "msg": "Only route rules can combine interface_in and interface_out"}

"Only route rules can combine interface_in and interface_out"というメッセージを見て「routeパラメータは使ってないはずなのにどういうことだ?」と一瞬混乱しましたが、該当箇所のコードを見て理解しました。

★参考:community.general/plugins/modules/ufw.py at main · ansible-collections/community.general · GitHub

要するに「"route"のパラメータ指定が無い、かつ、"interface_in"のパラメータ指定がある、かつ、"interface_out"のパラメータ指定がある」の場合にエラーになるということなので、後者2つの条件のみがtrueになった場合でも"Only route rules~" のエラーが出るということですね。

jinja2+Markdownによる再利用性の高い運用手順書

要約

  • 再利用性の高い運用手順書を作成するため、手順のモジュール化およびテンプレート化を試みた。
  • 1つの作業に対応する手順書を生成する際は、jinja2テンプレートに環境変数から取得したパラメータを埋め込み、Markdownファイルとして出力するスクリプトを実行する。
  • 各手順はディレクトリごとにモジュール化されており、再利用する際はjinja2のincludeで読み込む。
  • 何らかのCI/CDプロセスと組み合わせることで、Markdownファイルからスクリーンショットを埋め込んだpdfファイルを自動生成するなど、より実践的な手順書の生成も可能。

はじめに

システムの運用管理において、業務の標準化の観点から運用手順書の作成は欠かせません。そのため、運用手順書の作成・管理のプロセスを効率化することには重要な意義があると考えられます。

運用手順書の作成・管理においては、特に下記のような課題が散見されます。

  • 陳腐化:システムの構成が変わったが、関連する運用手順書の洗い出しおよびそれを更新する時間が捻出できずそのままになってしまう。結果、特定の作業者への属人化や現場判断での読み替えの常態化など、システムの信頼性に対するリスクが生じる。
  • バージョン管理:運用手順書の更新の際、いつ誰がどのような意図で変更を加えたかが可視化されない。そのため、ローカルにコピーした運用手順書が古い版であることに気が付かずそのまま手順を実行してしまうなど、システムの信頼性に対するリスクが生じる。
  • 既存手順の二重管理:運用手順書を作成する際、既存手順と重複する記載をした場合、その既存手順に変更が必要になった場合に合わせて変更する手間が生じる。しかし単純にハイパーリンクなどで参照させた場合、作業時に作業者がそのリンクを辿らなければならない。結果、特に時間的制約が厳しい作業においては、作業ミスを誘発する要因となり、システムの信頼性に対するリスクが生じる。
  • 手順とパラメータの混在:システムへの変更作業の安全性を検証するには、手順の妥当性とパラメータの妥当性とは別個に確認されなければならない。ここでパラメータとは、例えば作業対象ホストのような個々の変更作業に紐づく情報を指す。もし手順とパラメータを別個に管理する方法が無い場合、その運用手順書は変更作業の度にパラメータが埋め込まれた形で作成されるか、またはパラメータを全く含めない汎用的な記載方法で作成されるかしかない。この場合、前者はコピペミスなどの手順作成ミスを誘発し、後者はパラメータの考慮不足による作業ミスを誘発する。結果、システムの信頼性に対するリスクが生じる。

また運用手順書の作成には下記のような媒体が利用されますが、いずれも上記の課題全てに対する単純な解決手段は提供しません。

  • エクセル・・・陳腐化、バージョン管理、既存手順の二重管理の課題
  • Word・・・陳腐化、バージョン管理、既存手順の二重管理の課題
  • Wikiページ・・・既存手順の二重管理、手順とパラメータの混在の課題
  • その他メモ書き・・・陳腐化、既存手順の二重管理、手順とパラメータの混在の課題

本記事では、上記の各課題を解決可能な運用手順書について、jinja2+Markdownの組み合わせで実現する方法を述べます。

運用手順書リポジトリの概要

各種運用手順書をまとめたものとして、下記のようなディレクトリ構成でリポジトリを作成します。

ここでtest01およびtest02は、各種作業手順のモジュールとして1つのディレクトリにまとめられています。

.
├── header.md.j2
├── link_md.py
├── test01
│   ├── main.md.j2
│   ├── map.md.j2
│   └── parameters.yml
└── test02
    ├── main.md.j2
    ├── map.md.j2
    └── parameters.yml

各種ファイルの役割は下記の通りです。

  • header.md.j2:運用手順書のヘッダ情報に対応するjinja2テンプレート。
# {{ operation_name }}
- 作業の目的:{{ purpose }}
- 作業所要時間:{{ time }}
- 禁止事項:{{ forbidden_matter }}
  • link_md.py:作業手順に相当するjinja2テンプレートおよびパラメータに相当する環境変数を読み込み、パラメータを埋め込んだ運用手順書をMarkdownファイルとして生成するスクリプト
import os
import yaml
from jinja2 import Environment, FileSystemLoader

with open(os.environ["MANUAL_NAME"] + "/parameters.yml") as yml:
    parameter_keys = yaml.safe_load(yml)

parameters = {
    "MANUAL_NAME": os.environ["MANUAL_NAME"],
    "OPERATOR": os.environ["OPERATOR"]
}

for key in parameter_keys["parameters"]:
    parameters[key] = os.environ[key]

template = Environment(loader=FileSystemLoader(".")).get_template(os.environ["MANUAL_NAME"] + "/map.md.j2")

print(str(template.render(parameters)))
  • main.md.j2:モジュール化された作業手順の内容に対応するjinja2テンプレート。
## test01
The test parameter is {{ test }}
## test02
The test parameter is {{ test }}
  • map.md.j2:header.md.j2とモジュール化された作業手順を紐づけるjinja2テンプレート。このようなテンプレートを使うことで、容易に各作業手順の再利用が可能になる。
{% with operation_name = "operation_name01", purpose = "purpose01", time = "time01", forbidden_matter = "forbidden_matter01" %}
{% include "header.md.j2" %}
{% endwith %}
- 作業者:{{ OPERATOR }}

{% with  test = TEST %}
{% include "%s/main.md.j2" % MANUAL_NAME %}
{% endwith %}
{% with operation_name = "operation_name01", purpose = "purpose01", time = "time01", forbidden_matter = "forbidden_matter01" %}
{% include "header.md.j2" %}
{% endwith %}
- 作業者:{{ OPERATOR }}

{% with  test = TEST01 %}
{% include "test01/main.md.j2" %}
{% endwith %}

{% with  test = TEST02 %}
{% include "test02/main.md.j2" %}
{% endwith %}
  • parameters.yml:その作業手順において必要になるパラメータをまとめたもの
---
parameters:
  - TEST
---
parameters:
  - TEST01
  - TEST02

運用手順書の生成

以上を踏まえた上で、下記のようなコマンドを実行することで、1つの作業に対応する運用手順書のMarkdownテキストが生成されます。

vagrant@mgmt01:~/workdir/ops_document_builder$ export MANUAL_NAME=test01 OPERATOR=operator01 TEST=aaa; python3 link_md.py

# operation_name01
- 作業の目的:purpose01
- 作業所要時間:time01
- 禁止事項:forbidden_matter01

- 作業者:operator01


## test01
The test parameter is aaa
vagrant@mgmt01:~/workdir/ops_document_builder$ export MANUAL_NAME=test02 OPERATOR=operator01 TEST01=aaa TEST02=bbb; python3 link_md.py

# operation_name01
- 作業の目的:purpose01
- 作業所要時間:time01
- 禁止事項:forbidden_matter01

- 作業者:operator01


## test01
The test parameter is aaa



## test02
The test parameter is bbb

評価

上記のjinja2+Markdownによる運用手順書の作成方法が、先述の課題を容易に解決できることを確認します。

  • 陳腐化:1つの運用手順書リポジトリに情報が集約されており、かつモジュール化が容易なため、より少ないコストで関連する運用手順書の更新可能になっている。これにより、陳腐化の課題を解決している。
  • バージョン管理:全てテキストベースであるため、gitなどの既存のツールで容易にバージョン管理が可能になっている。これにより、バージョン管理の課題を解決している。
  • 既存手順の二重管理:新規手順作成時および既存手順更改時のいずれのタイミングでもモジュール化が容易であるため、より少ないコストで二重管理の状態を解消可能になっている。これにより、既存手順の二重管理の課題を解決している。
  • 手順とパラメータの混在:この運用手順書の作成方法では、手順を作成するタイミングとパラメータを埋め込むタイミングが明示的に分かれている。これにより、手順とパラメータの混在の課題を解決している。

今後の展望

上記のコマンド実行部分をCI/CDプロセスに乗せることで、続けてMarkdownからpdfを生成するなどより実践的な使い方も可能になると考えられます。

特にスクリーンショットが必要な運用手順書の場合、pdfへの画像埋め込みは重要です。