/

問題文

先輩がMySQLサーバを立てて、新しく運用を開始したらしい。

そのサーバを利用するため、rootパスワードを教えて貰ったため、今までどおりのログインコマンドを試してみた。

$ mysql -uroot -p -h 192.168.0.100

だが、なぜかログインエラーが出てしまい先輩のサーバにアクセスできない。先輩はサーバ上で正しく動いているらしいので、トラブルシューティングのためにそのサーバへのアクセス権限をもらった。

クライアントサーバから上記のコマンドを実行し、rootユーザがログインできるように変更を加え、下記の内容を報告してほしい。

  • ログインできるようになるためどのような作業を行ったのか
  • なぜログインが行えなかったのか

問題サーバー

クライアント

  • IPアドレス: 192.168.0.101
  • ユーザー: admin
  • パスワード: USerPw@19

サーバ

  • IPアドレス: 192.168.0.100
  • ユーザー: admin
  • パスワード: USerPw@19
  • DBユーザー: root
  • DBパスワード: root

問題解説

本問題ではMySQL 8系から導入された認証形式であるcaching_sha2_passwordが古いMySQLクライアントでは対応していないために発生しているエラーでした。

クライアントサーバから実際にログインしてみようとコマンドを実行すると以下のようなエラーが出ていたかと思います。

$ mysql -uroot -p -h 192.168.0.100
Enter password:
ERROR 2059 (HY000): Authentication plugin 'caching_sha2_password' cannot be loaded: /usr/lib/mysql/plugin/caching_sha2_password.so: cannot open shared object file: No such file or directory

このエラーメッセージからなんらかの認証回りでエラーが起きており、回答に気づくシナリオでした。

実際にMySQLサーバの起動しているホストにログインするとMySQL 8系のパッケージがインストールされており、クライアント側のサーバではMySQL 5.7.21のパッケージがインストールされていました。

具体的な回答としては以下のものを想定していました。

  • クライアント側のサーバにインストールされているMySQLクライアントのバージョンをcaching_sha2_passwordに対応したものをインストールする
    • 5.7系であれば5.7.23以上、8系であればどのバージョンでも対応しています
  • rootユーザのログインに用いる認証形式を古い形式に変更する
    • mysql_native_passwordに変更する事で古いMySQLクライアントでもログインすることが可能になります

ちなみに、問題文の中で「先輩がMySQLサーバを立てて運用を開始した」という文面がありましたが、実際にMySQLサーバ側からmysqlコマンドを用いると特にエラーが出力されないままログインに成功します。
MySQLサーバ側にはMuSQL 8系のパッケージがインストールされており、MySQLクライアントも8系のものがインストールされているためcaching_sha2_passwordに対応しておりログインが可能であった、というシナリオでした。

この問題を通して新しいMySQL認証プラグインであるcaching_sha2_passwordについて知っていただければ幸いです。

 /

問題文

あるデベロッパーから「dev環境と間違えて、prod環境のMySQLで直接truncate table sbtest3;を実行してsbtest3テーブルのデータを削除してしまいました、復旧をお願いします!!」と緊急の連絡を受けました。
このクエリの実行後、すぐに間違いに気づいたデベロッパーはサービスをメンテナンス状態にし、アプリケーションサーバを停止したそうです。

問1(q01)

truncate table sbtest3; というクエリが実行された日時をyymmdd HH:MM:SSのフォーマットで報告してください。
また、どのようにこの日時を特定したかを説明してください。

問2(q02)

truncate tableが実行される直前の状態(truncate tableが実行される1つ前のクエリが実行された状態)にデータを復旧し、復旧後checksum table sysbench.sbtest3;の結果を報告してください。
また、データの復旧に必要な手順を説明してください。

情報

状況

  • このMySQLは毎日定時にsysbench databaseのバックアップを取得していて(コンテスト問題の作成上truncate table文が実行された日まで)、偶然truncate文が実行される(数分)前にこの日のバックアップが完了していた
  • バックアップは以下のコマンドで取得されている
  • mysqldump --opt --single-transaction --master-data=2 --default-character-set=utf8mb4 --databases sysbench > /root/backup/backup.dump
  • mysql -u root -p < /root/backup/backup.dumpでバックアップが取得された時点に復旧できる
  • adminユーザからsudo suすることでrootユーザから操作してください

問題サーバー

  • IPアドレス: 192.168.0.1
    ユーザー: admin
    パスワード: USerPw@19
  • DBユーザー: root
  • DBパスワード: root

ゴール

  • 問1, truncate table sbtest3; というクエリが実行された日時を特定し、その特定方法を説明していること
  • 問2, MySQLのデータが復旧し、その手順の説明とchecksum table sysbench.sbtest3;の結果が報告されていること
  • 問1に正解すると部分点が与えられます。

問題解説

「本番環境で間違ってtruncate table ...を実行してしまったから、その直前の状態までデータ復旧してほしい!!」
というデータベースの復旧問題でした。

あまり想像して楽しくなる状況ではありませんが、ソフトウェア開発をする上でデータベースのデータの復旧方法を知っておいても損はないと思います。
そこで、MySQLを例にバックアップと更新ログを利用して任意のタイミングまでデータを復旧する問題を出題しました。
DBMSごとの際はありますが、基本的にデータベースの復旧方法(特にロールフォーワードリカバリ)の概念は同じですので、今後使うDBMSでこういった状況に対応できるかを考えてみてください。

具体的な解法を説明する前にMySQLにおけるリカバリ方法、バイナリログ、バックアップについて説明します。

ポイント・イン・タイム・リカバリ (PITR)

データベースのバックアップを定期的に取得するだけでは、その時点のデータを復元することしかできないため、バックアップ以降の更新データを失うことになります。
そこで、データの永続化を保証する多くのデータベースでは、バックアップとは別にデータの更新情報を保持しておき、バックアップとそれ以降のデータ更新を復元することで任意の時点のデータをリカバリできるようにしています。このようなリカバリ方法をポイントインタイムリカバリ(PITR)と言います。

バックアップ以降のデータ更新情報をどのように保持するかはDBMSによって異なりますが、MySQLでは後述するバイナリログを利用することで取得できます。

バイナリログ

バイナリログにはMySQL serverで実行された更新がイベントという形で出力されています。
イベントには直接実行されたクエリが出力されているわけではなく、行ごとの変更分が特定のフォーマットにしたがって出力されています。
この他にもバイナリログそのもののバージョンやサーバの設定などのメタ情報も含まれており、これを利用してリカバリやレプリケーションを行うことができます。
truncate table ...を含むDDLはSTATEMENTが直接書かれていたり、推奨されませんが、DMLを直接STATEMENTに出力したりも出来ます。詳しくはバイナリログのドキュメントを参照してください

バイナリログの中身はmysqlbinlogコマンドを使うことで確認でき、より詳細に確認したい場合は--base64-output=DECODE-ROWS -vvなどのオプションをつけることで、実際に実行されたSQL文とは異なりますが、SQL文として復元されたものを確認できます。ただしクエリはコメントアウトされているので、このオプションをつけた結果をリカバリに使うことは出来ません。

バイナリログから特定の期間の更新を取得するには時間で指定する方法とバイナリログ中のオフセットを利用する方法がありますが、時間単位での指定は秒単位でしかできないため、リカバリにはオフセットを用いて指定する --start-position,--stop-positionを利用することをおすすめします。(秒間1クエリしか実行していない、なんてサービスはほとんどないはず)

バックアップ

MySQLにおけるバックアップ方法はいくつもあり実際は要件に合わせて選択しますが、今回はMySQL標準の論理バックアップクライアントのmysqldumpを利用してバックアップしました。
オプションの説明は省略しますが、問題環境では以下のコマンドでsysbench databaseの完全バックアップが取得されていました。

mysqldump --opt --single-transaction --master-data=2 --default-character-set=utf8mb4 --databases sysbench > /root/backup/backup.dump

--master-data=2が指定されていることから、バックアップを行ったホストに対してレプリケーションを繋ぐ際のコマンド(CHANGE MASTER...)がコメントアウトされて出力されます。この出力を見ることで、バックアップ以降の更新がバイナリログのどこから始まっているかをバイナリログのオフセットとして確認できます。

復旧手順

それでは実際に今回の問題環境でPITRを実施してデータを復旧する方法を説明します。

PITRの項で説明したように、まずはバックアップをリストアし、バックアップ以降でtruncate table sbtest3;の実行直前までの更新をバイナリログから抽出して適用することで復旧します。

mysqldumpによるダンプファイルの22行目に以下の出力があることからバックアップ以降の更新はbinlog.000018の34626719byte目以降ということがわかります。

-- CHANGE MASTER TO MASTER_LOG_FILE='binlog.000018', MASTER_LOG_POS=34626719;

次に、バイナリログからmysqlbinlogコマンドを使ってtruncate table...を実行を表すイベントのオフセットを取得します。
問題環境ではデータの更新が記録されているのはbinlog.000018だけなので、このファイルについてだけ考えれば良いです。

$ mysqlbinlog --no-defaults --base64-output=DECODE-ROWS -vv --start-position=34626719  binlog.000018 | grep -B 10 truncate
/*!80001 SET @@session.original_commit_timestamp=1574235055802260*//*!*/;
/*!80014 SET @@session.original_server_version=80018*//*!*/;
/*!80014 SET @@session.immediate_server_version=80018*//*!*/;
SET @@SESSION.GTID_NEXT= 'ANONYMOUS'/*!*/;
# at 54452036
#191120 16:30:55 server id 1  end_log_pos 54452141 CRC32 0x7e67e1d2     Query   thread_id=11    exec_time=0 error_code=0    Xid = 189179
use `sysbench`/*!*/;
SET TIMESTAMP=1574235055/*!*/;
/*!\C utf8mb4 *//*!*/;
SET @@session.character_set_client=255,@@session.collation_connection=255,@@session.collation_server=255/*!*/;
truncate table sbtest3

この出力の# at {数字}を見ることでtruncate文を実行したのが54452036 byte目から始まるイベントとわかります。
また、次の行をみると191120 16:30:55に実行されたことがわかります (問1)

問1としてあえて秒単位でしかない実行時刻を解答してもらったのは、実際にこういった問題が起こった場合には似たようなクエリがアプリケーションの正常な処理として実行されていたり、実は何回も間違った後だったりするため確認作業を必要とすることが多いからです。
辛いですね。。。

ここまででわかったbinlogの状況を図1に整理してみます。

スクリーンショット 2019-12-08 15.10.27.png
図1 binlogの状況

バックアップを適用することで②の直前の状態にまでデータが復旧されるため、②から③までの更新情報を適用する必要があります。
①から②の部分も適用したチームも見られましたが、この部分の更新分はバックアップに含まれているので、重複した更新を適用してしまうことになります。

最後にここまでで取得したオフセットを--start-position--stop-positionで指定して抽出し、適用することでリカバリが完了します(問2)

mysqlbinlog --no-defaults --start-position=34626719 --stop-position=54452036 binlog.000018 | mysql -u root -p
# または
mysqlbinlog --no-defaults --start-position=34626719 --stop-position=54452036 binlog.000018 > recover.sql
mysql -u root -p < recover.sql

採点基準

  • 問1: 30%
  • 問2: 70%

問2の採点にはリカバリ後に実行してもらったchecksum table ...の結果を利用するつもりでしたが、問題環境ではバイナリログからの抽出位置を正しく指定しなくてもchecksumが同じ値になってしまうことがありました。
そのため解答中のリカバリ方法の説明で、バックアップ以降、truncate table文まで(図1の②~③部分)の更新分を抽出、適用していることを確認して正解としました。

 /

問題文

職場でkubernetesを用いてRedmineを導入することになった。
上司が構築作業をしていたが、どうもうまくRedmineが起動しないらしい。

部下のあなたがk8sが得意ということだったので、構築作業の続きをすることになった。
kubernetesが動作するサーバーにRedmine用のManifestが適用されているが、どうも正常起動していないようだ。

原因を究明を行い、Redmineを使える状態にして解決方法を報告してください。

問題のゴール

  • VNCクライアントのブラウザからRedmineが閲覧できること。
    http://192.168.0.100:30000
  • Redmineのデータがコンテナ再起動時にも保持されていること。

情報

  • Server:
  • k8smaster1:
    • ip: 192.168.0.100
    • userid: root
    • password: USerPw@19
  • container-registry:
    • ip: 192.168.0.101
    • 備考: 操作不可
  • Redmine_Manifest:
    • path: “/root/ictsc_problem_manifests/*.yaml”
  • Redmineログイン情報
    • userid: ictsc
    • password: USerPw@19

制限事項

  • Redmineは指定のManifest(Redmine_Manifest)でデプロイしてください。
  • Redmine_Manifestは変更出来ません。
  • Redmine_Manifest内のコンテナイメージはcontainer-registryから取得してください。
  • マニフェストの再適用, OSの再起動の操作は可能です。
  • 誤操作等で競技続行不可の場合は出題時環境への復元のみ承ります。

Kubernetes上にRedmineサービスを稼働させる問題です。
出題時にはRedmineを構成するRedmine-Pod, MariaDB-PodがPendingとなっており、利用不可の状態です。
コンテナが稼働しない原因を突き止め対処することでRedmineサービスを稼働させることができます。

問題解決のために以下の原因を解決する必要があります。

  1. masterへpodのデプロイに関するtaintsの削除
  2. コンテナランタイムcri-oにinsecure-registryの設定を追加
  3. MariaDBのPersistentVolumeのディレクトリ権限(Permission)を修正

問題解説

Kubernetes上にRedmineサービスを稼働させる問題です。
出題時にはRedmineを構成するRedmine-Pod, MariaDB-PodがPendingとなっており、利用不可の状態です。
コンテナが稼働しない原因を突き止め対処することでRedmineサービスを稼働させることができます。

masterへpodのデプロイに関するtaintsの削除

kubectl get podでコンテナの状態を見ます。

[root@k8smaster1 ~]# kubectl get pod
NAME                                  READY   STATUS    RESTARTS   AGE
my-release-mariadb-0                  0/1     Pending   0          9d
my-release-redmine-859cf77958-n95j5   0/1     Pending   0          9d

redmineとmariadbがpendingになっています。
kubectl describe pod <pod名> で各Podの状態を確認すると以下のイベントが確認できます。

Events:
  Type     Reason            Age                 From               Message
  ----     ------            ----                ----               -------
  Warning  FailedScheduling  9d (x5 over 9d)     default-scheduler  0/1 nodes are available: 1 node(s) had taints that the pod didn't tolerate.

nodeのtaintsをpodが許容できないということなので、nodeのtaintskubectl describe nodesで確認します。

[root@k8smaster1 ~]# kubectl describe nodes
Name:               k8smaster1
Roles:              master
Labels:             beta.kubernetes.io/arch=amd64
                    beta.kubernetes.io/os=linux
                    kubernetes.io/arch=amd64
                    kubernetes.io/hostname=k8smaster1
                    kubernetes.io/os=linux
                    node-role.kubernetes.io/master=
Annotations:        kubeadm.alpha.kubernetes.io/cri-socket: /var/run/crio/crio.sock
                    node.alpha.kubernetes.io/ttl: 0
                    volumes.kubernetes.io/controller-managed-attach-detach: true
CreationTimestamp:  Sat, 23 Nov 2019 19:58:55 +0900
Taints:             node-role.kubernetes.io/master:NoSchedule

Taintsがnode-role.kubernetes.io/master:NoScheduleのため、Labelsにmasterが指定されているこのノードにはPodをScheduleすることができません。
今回はシングルノードのMasterノードでデプロイさせたいので、このtaintsを削除します。

[root@k8smaster1 ~]# kubectl taint nodes k8smaster1 node-role.kubernetes.io/master:NoSchedule-
node/k8smaster1 untainted

これでPodがノードにScheduleされるようになりました。

コンテナランタイムcri-oにinsecure-registryの設定を追加

再度kubectl get podで確認すると以下となりImagePullBackOffとなっています。

[root@k8smaster1 ~]# kubectl get pod
NAME                                  READY   STATUS             RESTARTS   AGE
my-release-mariadb-0                  0/1     ImagePullBackOff   0          9d
my-release-redmine-859cf77958-n95j5   0/1     ImagePullBackOff   0          9d

kubectl desctibe pod <Pod名>でEventを確認すると以下のエラーが確認できます。

Failed to pull image "private-registry.local/bitnami/mariadb:10.3.20-debian-9-r0": rpc error: code = Unknown desc = pinging docker registry returned: Get https://private-registry.local/v2/: dial tcp 192.168.0.101:443: connect: no route to host

コンテナイメージのpullの際に192.168.0.101:443への接続ができていません。
情報で示されているように192.168.0.101はContainer-Registryです。

/etc/hosts192.168.0.101 private-registry.localの記載があり、正しいことがわかります。

試しにDockerでImageのpullに失敗したコンテナイメージを手動でdocker pull private-registry.local/bitnami/mariadb:10.3.20-debian-9-r0してみます。
この操作は正常にpullすることができ、docker imagesでも正しくコンテナイメージがあることが確認できました。

[root@k8smaster1 ~]# docker images
REPOSITORY                               TAG                   IMAGE ID            CREATED             SIZE
private-registry.local/bitnami/mariadb   10.3.20-debian-9-r0   36300b3aaaa0        3 weeks ago         289 MB

ここで再度k8s上のPodの状態を確認しますが、エラーに変化はありません。

[root@k8smaster1 ~]# kubectl get pod
NAME                                  READY   STATUS             RESTARTS   AGE
my-release-mariadb-0                  0/1     ImagePullBackOff   0          9d
my-release-redmine-859cf77958-n95j5   0/1     ImagePullBackOff   0          9d

さらに詳細にみるために全namespaceを対象にget podします。

[root@k8smaster1 ~]# kubectl get pod --all-namespaces
NAMESPACE     NAME                                  READY   STATUS             RESTARTS   AGE
default       my-release-mariadb-0                  0/1     ImagePullBackOff   0          9d
default       my-release-redmine-859cf77958-n95j5   0/1     ImagePullBackOff   0          9d
kube-system   coredns-74c9d4d795-hj9tn              1/1     Running            3          9d
kube-system   coredns-74c9d4d795-l7949              0/1     Pending            0          9d
kube-system   dns-autoscaler-7d95989447-crv67       1/1     Running            3          9d
kube-system   kube-apiserver-k8smaster1             1/1     Running            3          9d
kube-system   kube-controller-manager-k8smaster1    1/1     Running            3          9d
kube-system   kube-proxy-mm8ld                      1/1     Running            3          9d
kube-system   kube-scheduler-k8smaster1             1/1     Running            3          9d
kube-system   nodelocaldns-wgq47                    1/1     Running            3          9d
kube-system   weave-net-z97jl                       2/2     Running            6          9d

RunningのPodがあるのに、docker imagesでは手動でpullしたイメージしかありませんでした。

何かおかしいですね。Dockerが使われていないのかもしれません。

k8sのコンテナランライムを確認するためにkubeletのユニットファイルを確認します。
ファイル/etc/systemd/system/kubelet.serviceに記載の環境変数ファイル/etc/kubernetes/kubelet.envには以下の記載があります。

KUBELET_ARGS="--bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf \
--config=/etc/kubernetes/kubelet-config.yaml \
--kubeconfig=/etc/kubernetes/kubelet.conf \
--pod-infra-container-image=gcr.io/google_containers/pause-amd64:3.1 \
--container-runtime=remote \
--container-runtime-endpoint=/var/run/crio/crio.sock \
--runtime-cgroups=/systemd/system.slice \
 --node-labels=  "

container-runtimecrioが指定されていることがわかります。
しっかりとサービスが動いていますね。

[root@k8smaster1 ~]# systemctl status crio
● crio.service - Open Container Initiative Daemon
   Loaded: loaded (/usr/lib/systemd/system/crio.service; enabled; vendor preset: disabled)
   Active: active (running) since Tue 2019-12-03 00:39:10 JST; 49min ago
     Docs: https://github.com/kubernetes-sigs/cri-o
 Main PID: 1076 (crio)
   CGroup: /system.slice/crio.service
           └─1076 /usr/bin/crio

crioはCRIなのでcrictlによる操作が可能です。
crictl imagesの結果ではrunningだったPodのコンテナイメージがありました。

[root@k8smaster1 ~]# crictl images
IMAGE                                              TAG                 IMAGE ID            SIZE
docker.io/coredns/coredns                          1.6.0               680bc53e5985f       42.3MB
docker.io/weaveworks/weave-kube                    2.4.0               86ff1a48ce14d       134MB
docker.io/weaveworks/weave-npc                     2.4.0               647ad6d59818d       49.5MB
gcr.io/google-containers/kube-apiserver            v1.15.3             5eb2d3fc7a44e       208MB
gcr.io/google-containers/kube-controller-manager   v1.15.3             e77c31de55475       160MB
gcr.io/google-containers/kube-proxy                v1.15.3             232b5c7931462       84.3MB
gcr.io/google-containers/kube-scheduler            v1.15.3             703f9c69a5d57       82.7MB
k8s.gcr.io/cluster-proportional-autoscaler-amd64   1.6.0               dfe4432cd2e2b       48.9MB
k8s.gcr.io/k8s-dns-node-cache                      1.15.4              3f8330c31e7d5       64.3MB
k8s.gcr.io/pause                                   3.1                 da86e6ba6ca19       747kB
gcr.io/google-containers/pause                     3.1                 da86e6ba6ca19       747kB

k8sのコンテナランタイムとしてcri-oが動作していたので、80ポートでアクセスさせるために、cri-oのprivate-resigtryへのinsecure-registryを追加しましょう。

214  # insecure_registries is used to skip TLS verification when pulling images.
215  insecure_registries = [
216    "10.233.0.0/24",
217    "private-registry.local"
218  ]

MariaDBのPersistentVolumeのディレクトリ権限(Permission)を修正

crioの設定を反映させた後、再度k8sのpodを確認します。

[root@k8smaster1 ~]# kubectl get pod
NAME                                  READY   STATUS    RESTARTS   AGE
my-release-mariadb-0                  0/1     Error     5          9d
my-release-redmine-859cf77958-n95j5   0/1     Running   1          9d

mariadbがエラーになっています。
コンテナの立ち上げは行われているので、コンテナのlogを確認します。

[root@k8smaster1 ~]# kubectl logs  my-release-mariadb-0
 16:43:21.98
 16:43:21.98 Welcome to the Bitnami mariadb container
 16:43:21.98 Subscribe to project updates by watching https://github.com/bitnami/bitnami-docker-mariadb
 16:43:21.98 Submit issues and feature requests at https://github.com/bitnami/bitnami-docker-mariadb/issues
 16:43:21.98 Send us your feedback at containers@bitnami.com
 16:43:21.99
 16:43:21.99 INFO  ==> ** Starting MariaDB setup **
 16:43:22.04 INFO  ==> Validating settings in MYSQL_*/MARIADB_* env vars
 16:43:22.04 INFO  ==> Initializing mariadb database
mkdir: cannot create directory '/bitnami/mariadb/data': Permission denied

'/bitnami/mariadb/data': Permission denied ディレクトリの権限不足のようですね。

/root/ictsc_problem_manifestsにあるk8sManifestを読み解くと、/var/opt/pv{1,2}にPersistentVolumeがあることがわかります。
kubectl get pvの結果よりmariaDBに対応するPathにchmod で権限を付与します。

[root@k8smaster1 ictsc_problem_manifests]# kubectl get pv
NAME     CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                               STORAGECLASS   REASON   AGE
pv0001   20Gi       RWO            Recycle          Bound    default/data-my-release-mariadb-0                           9d
pv0002   20Gi       RWO            Recycle          Bound    default/my-release-redmine                                  9d

[root@k8smaster1 ]# chmod -R 777 /var/opt/pv1/

再度k8sのPodの状況を確認すると正常にRedmineのPodが稼働していることが確認できました。

[root@k8smaster1 opt]# kubectl get pod
NAME                                  READY   STATUS             RESTARTS   AGE
my-release-mariadb-0                  1/1     Running            9          9d
my-release-redmine-859cf77958-n95j5   1/1     Running            5          9d

VNCのブラウザからRedmineのページが閲覧できれば完了です。

 /

問題文

匿名で日記が投稿できるサービス「すごく匿名ダイヤリー」を運営しています。
従来、フロントエンドとバックエンドを同じドメインで運用していましたが、
構成変更のため、バックエンドをサブドメインに変更する作業を行っています。

変更前:
https://old-diary.ictsc.net/
https://old-diary.ictsc.net/api/

変更後:
https://new-diary.ictsc.net/
https://api.new-diary.ictsc.net/

※VNCサーバのWebブラウザからのみ閲覧可能です

ソースコード内のドメインやパスは適切に書き換えましたが、何故か正常に動作しません。
変更前と同じように各機能が動作するよう、サーバにログインして原因調査 及び 修正を行ってください。

なお、サービスはメンテナンス中で限定公開としているため、対応中にサービス断が生じても問題ありません。
また、投稿データについてもバックアップから復元するので、(変更前/変更後環境共に)日記の追加・削除・スター追加は任意に実施して問題ありません。

今後の運用・開発を考慮し、変更は問題解決に必要な箇所に絞り、出来るだけ他に影響を与えないように直してください。
全てを直しきれない場合でも、可能なところまで直してください。

サービス仕様

  • 誰でも匿名で日記が投稿・閲覧できる
  • 投稿されている日記に対して誰でもスターを付けることができる
  • 日記は投稿したブラウザで閲覧すると削除ボタンが表示され、削除が可能 (期間/個数に制限あり)
  • フロントエンドはSPA(Single Page Application)として構築されている
  • 日記の取得/投稿/削除/スター追加はWebAPI経由でバックエンドと通信して実現する

解答方法

  • 修正 と 報告 の両方が必要です
  • 「変更後」のURLでサービスが正常に動作するよう、実際にサーバ上で修正を行ってください。
  • 解答から「原因と実施した修正内容」を報告してください。
    • 報告は最終的に行った内容のみで問題ありません (途中の試行錯誤は記載不要)
    • 具体的に記載してください (例: XXXを直した、ではなく XXXがXXXなので、XXXファイルのXXX部分にXXXXXXXXXを追加した 等)

ログイン情報

VNCサーバから
$ ssh 192.168.0.80 -l admin
→ PW: USerPw@19

※ $ sudo su – にて rootユーザに昇格可能です

問題解説

この問題はICTSC2019 一次予選にて出題された APIが飛ばないんですけど… の実技出題を目的として作成しました。
機能ごとに必要な対処が異なり、CrossOrigin通信におけるCORS, CSP, Cookieの取り扱いを把握していないと完答出来ない構成としています。

STEP1, 日記一覧と日記を閲覧可能にする 前半 (CSPによる許可)

https://new-diary.ictsc.net/ を閲覧するとブラウザアラートでError: Network Errorと表示されます。
これだけでは原因がわからないので、開発者ツール(F12)のコンソールを表示すると以下のエラーが表示されています。

Content Security Policy: ページの設定により次のリソースの読み込みをブロックしました: https://api.new-diary.ictsc.net/list (“connect-src”)

→ CSPの “connect-src” で https://api.new-diary.ictsc.net/list への接続が禁止されていることが分かります。
ページのソースを表示するとmetaタグでCSPが指定されている為、このhtmlを修正する必要があります。

<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com;">

修正すべきファイルの場所は動作しているWebサーバの設定ファイルから特定します。

# netstat -ntelpo | grep -e :443
tcp6       0      0 :::443                  :::*                    LISTEN      0          26477      2044/httpd           off (0.00/0/0)
# ps auxww | grep http[d]
root      2044  0.0  1.3 286180 13864 ?        Ss   17:59   0:00 /usr/sbin/httpd -DFOREGROUND
apache    2778  0.0  0.9 298736  9104 ?        S    18:48   0:00 /usr/sbin/httpd -DFOREGROUND
apache    2779  0.0  1.5 1356304 15728 ?       Sl   18:48   0:00 /usr/sbin/httpd -DFOREGROUND
apache    2780  0.0  1.6 1356172 16760 ?       Sl   18:48   0:00 /usr/sbin/httpd -DFOREGROUND
apache    2781  0.0  1.7 1487424 18028 ?       Sl   18:48   0:00 /usr/sbin/httpd -DFOREGROUND
apache    2993  0.0  1.6 1356308 17012 ?       Sl   18:48   0:00 /usr/sbin/httpd -DFOREGROUND
# /usr/sbin/httpd -S 2>&amp;1 | grep port
         port 443 namevhost fe80::9ea3:baff:fe30:1584 (/etc/httpd/conf.d/ssl.conf:40)
         port 443 namevhost old-diary.ictsc.net (/etc/httpd/conf.d/virtualhost.conf:6)
         port 443 namevhost new-diary.ictsc.net (/etc/httpd/conf.d/virtualhost.conf:32)
         port 443 namevhost api.new-diary.ictsc.net (/etc/httpd/conf.d/virtualhost.conf:51)
# grep Root -B1 /etc/httpd/conf.d/virtualhost.conf
  ServerName old-diary.ictsc.net
  DocumentRoot /var/www/old-front
--
  ServerName new-diary.ictsc.net
  DocumentRoot /var/www/new-front
--
  ServerName api.new-diary.ictsc.net
  DocumentRoot /var/www/new-api/public

/var/www/new-front/index.html に該当のmetaヘッダが含まれている為、
connect-src 'self';connect-src https://api.new-diary.ictsc.net; に編集すると、問題のエラーが解消します。

STEP2, 日記一覧と日記を閲覧可能にする 後半 (CORSによる許可)

STEP1でCSPによるエラーは解消しましたが、まだ閲覧可能にはなりません。
再び https://new-diary.ictsc.net/ を開いてコンソールを確認すると、以下のエラーが表示されます。

クロスオリジン要求をブロックしました: 同一生成元ポリシーにより、https://api.new-diary.ictsc.net/list にあるリモートリソースの読み込みは拒否されます (理由: CORS ヘッダー ‘Access-Control-Allow-Origin’ が足りない)。

記載の通り、CORSヘッダーの設定が必要となります。
https://developer.mozilla.org/ja/docs/Web/HTTP/CORS
設定場所についてはいくつか考えられますが、作問者の想定は以下の2通りです。

アプリケーション側に追加

/var/www/new-api/public/index.phpFastRoute\Dispatcher::FOUND 以下等に追加する

    case FastRoute\Dispatcher::FOUND:
        $handler = $routeInfo[1];
        $vars = $routeInfo[2];
        header('Access-Control-Allow-Origin: https://new-diary.ictsc.net'); ★ 追加

Webサーバ(Apache)側に追加

/etc/httpd/conf.d/virtualhost.conf<Directory /var/www/new-api/public> 内等に追加し、httpdをreloadする

  <Directory /var/www/new-api/public>
    Options Indexes FollowSymLinks
    AllowOverride All
    Require all granted

    Header set Access-Control-Allow-Origin https://new-diary.ictsc.net ★ 追加
  </Directory>

※ 本問題ではブラウザ上で各機能が正しく動作していれば、追加場所や細かい記載方法等は不問としました。
※ ただし、アプリケーションを1から作り直すような大幅な変更は認めていません。

以上の変更を行うと、日記一覧 及び 日記が閲覧可能となります。

STEP3, 日記の投稿を可能にする

「日記を書く」から日記を投稿すると、ブラウザアラートで投稿後の日記URLが受け取れませんでした。と表示されます。
また、コンソールにはsubmit_article https://new-diary.ictsc.net/app.js:109 と表示されます。
ただし、日記の投稿は正常に完了しており、その後のページ遷移のみ失敗しているようです。

エラーメッセージだけでは情報が足りないので、https://new-diary.ictsc.net/app.jsの該当処理を確認すると、
res.headers.location、つまりレスポンスのLocationヘッダが正常に取得出来ていないようです。

        axios.post(api_url + 'article', params)
            .then(res => {
            if (!res.headers.location) { throw `投稿後の日記URLが受け取れませんでした。` }
            router.push(res.headers.location)
            })
            .catch(err => { console.error(err); alert(err) })
        }

一方、開発者ツールのネットワークタブでAPIサーバからの応答を確認すると、
日記投稿後、Location: /article/21 のようにLocationヘッダを含むレスポンスが得られていると確認出来ます。

この解決には知識が必要となりますが、CORSでセーフリスト以外のレスポンスヘッダを利用する場合、
Access-Control-Expose-Headers ヘッダにて明示的に許可する必要があります。
https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Access-Control-Expose-Headers
Locationヘッダはセーフリストに含まれていない為、STEP2の設定に以下のヘッダも追加する必要があります。

Access-Control-Expose-Headers: Location

ヘッダを追加すると、日記投稿後のエラーが解消し、投稿された日記ページにリダイレクトされるようになります。

STEP4, スターの追加を可能にする

各記事のスター追加ボタン[★+]をクリックするとError: Network Errorが表示されます。
開発者ツールのコンソールには以下のように表示されます。

クロスオリジン要求をブロックしました: 同一生成元ポリシーにより、https://api.new-diary.ictsc.net/article/26/star にあるリモートリソースの読み込みは拒否されます (理由: CORS ヘッダー ‘Access-Control-Allow-Origin’ が足りない)。
クロスオリジン要求をブロックしました: 同一生成元ポリシーにより、https://api.new-diary.ictsc.net/article/26/star にあるリモートリソースの読み込みは拒否されます (理由: CORS 要求が成功しなかった)。

また、開発者ツールのネットワークタブで通信を確認すると、
OPTIONSメソッドのリクエストが送信され、HTTP/1.1 405 Method Not Allowedのレスポンスが得られています。
しかし、https://new-diary.ictsc.net/app.jsにて利用されているメソッドはPUTです。

        add_star: function () {
            axios.put(api_url + 'article/' + this.$route.params.id + '/star')
                .then(res => {
                this.article.star_count++
                })
                .catch(err => { console.error(err); alert(err) })
        },

これは一次予選でも出題された プリフライトリクエストによる挙動です。
https://developer.mozilla.org/ja/docs/Web/HTTP/CORS#Preflighted_requests

OPTIONSメソッドに対して適切なCORSヘッダを応答する必要がありますが、
/var/www/new-api/public/index.php内でOPTIONSメソッドが定義されていない為、METHOD_NOT_ALLOWEDとして405の応答が発生しています。

作問者の想定解法は以下の2通りです。

ダミールートの追加

/var/www/new-api/public/index.php に ダミーのルートを追加する

$base = '/';
$dispatcher= FastRoute\simpleDispatcher(function(FastRoute\RouteCollector $router) use ($base) {
    $router->addRoute('GET'    , $base.'list'                  , 'get_list');
    $router->addRoute('GET'    , $base.'article/{id:\d+}'      , 'get_article');
    $router->addRoute('POST'   , $base.'article'               , 'post_article');
    $router->addRoute('DELETE' , $base.'article/{id:\d+}'      , 'delete_article');
    $router->addRoute('PUT'    , $base.'article/{id:\d+}/star' , 'put_article_star');
    $router->addRoute('OPTIONS', $base.'{path:.*}'             , 'dummy');   ★ 追加
});

function dummy($vars, $pdo) {  ★ 追加
    return;
}

合わせてCORSヘッダの設定箇所に以下を追加する必要があります。

Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS

METHOD_NOT_ALLOWED発生時の処理に追加

/var/www/new-api/public/index.phpMETHOD_NOT_ALLOWEDが発生時した場合も、OPTIONSメソッドについては応答するように追加する

    case FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
        $allowedMethods = $routeInfo[1];
        if ($httpMethod == 'OPTIONS') {  ★ 追加
            header('Access-Control-Allow-Methods: OPTIONS, '.implode(', ', $allowedMethods));
            header('Access-Control-Allow-Origin: https://new-diary.ictsc.net');
            header('Access-Control-Expose-Headers: Location');
            break;
        }
        header('Allow: '.implode(', ', $allowedMethods));
        header('HTTP/1.1 405 Method Not Allowed');
        break;

上記どちらかの修正を行うと、スターの追加が可能となります。

STEP5, 日記の削除を可能にする

ここまでの対処でブラウザ操作で発生するエラーは解消しました。
しかし、問題文に書かれている日記の削除機能が見当たりません。

  • 日記は投稿したブラウザで閲覧すると削除ボタンが表示され、削除が可能 (期間/個数に制限あり)

https://new-diary.ictsc.net/app.js を確認すると、UI自体は存在するようですが、
article.authoredtrueにならなければ表示されないようです。

            <div><span v-if="article.authored" class="delete_btn" v-on:click="delete_article()">この日記を削除する</span></div>

https://new-diary.ictsc.net/app.jsにはarticle.authoredを変更する処理が含まれておらず、
APIからの結果をそのまま受け入れています。

    mounted: function () {
        axios.get(api_url + 'article/' + this.$route.params.id)
        .then(res => {
            if (!res.data) { throw `日記が見つかりませんでした。` }
            this.article = res.data;
        })
        .catch(err => { console.error(err); alert(err) })
    },

API側の処理を /var/www/new-api/public/index.php から確認すると、
Cookieに正しいsecret(パスワード)が保存されている場合のみ、article.authoredtrueとなることが分かります。

function get_article($vars, $pdo) {
    $articleid = $vars['id'];

    $stmt = $pdo->prepare('SELECT id, title, content, star_count, secret_hash FROM article WHERE id = :id');
    $stmt->execute(array(':id' => $articleid));
    $result = $stmt->fetch();
    if (isset($_COOKIE['__Secure-article-'.$articleid])) {
        $secret_hash = $result['secret_hash'];
        $client_secret = $_COOKIE['__Secure-article-'.$articleid];
        $authored = password_verify($client_secret, $secret_hash);
    } else {
        $authored = false;
    }

記事の投稿時にはsetcookieが行われており、レスポンスヘッダからも確認できますが、
実際に投稿してもブラウザのCookieには保存されません。※ 開発者ツールのストレージタブにて確認出来ます。

function post_article($vars, $pdo) {
...
    header('HTTP/1.1 201 Created');
    header('Location: /article/'.$articleid);
    setcookie('__Secure-article-'.$articleid, $secret, time() + (365 * 86400), '/', 'new-diary.ictsc.net', $_SERVER["HTTPS"]);

CrossOriginでCookieを設定させる場合は、リクエスト側でwithCredentialsの指定と、
レスポンス側でAccess-Control-Allow-Credentialsの指定が必要となります。
https://developer.mozilla.org/ja/docs/Web/API/XMLHttpRequest/withCredentials
https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials

レスポンス側はこれまでのCORSヘッダと同様に以下のヘッダを追加します。

Access-Control-Allow-Credentials: true

リクエスト側については、/var/www/new-front/app.js からaxiosを利用して通信している為、
個別にwithCredentials: trueを指定するか、以下のようにデフォルト値を設定します。

axios.defaults.withCredentials = true;

双方を追加後に記事を投稿すると「この日記を削除する」ボタンが表示されるようになります。
実際の削除についてはDELETEメソッドを許可する必要があるため、追加していない場合はヘッダに追加します。

Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS

以上で全ての機能が正常に動作するようになりました。
動作確認の上、「原因と修正内容」を解答すれば完了です。

採点結果について

本問題は「各機能の正常な動作」及び「修正箇所への言及」にて点数を加算しています。

各工程の正答率は「STEP1/2 41%」「STEP3 21%」「STEP4 23%」「STEP5 17%」となり、完答は「12%」でした。
STEP1/2までの修正についてはWebブラウザの開発者ツール(コンソール)で修正箇所が示されていますので、
普段から使い慣れている方は比較的容易に解決できる想定でした。
一方、STEP3/4/5についてはCORS/Cookieの知識 及び PHP/JavaScriptの読解が必要となる為、
Web技術に関するチームの実力差が顕著に出る結果となったように感じます。
特に上位チームは解答内容が丁寧かつ明確な内容で、完全に理解している様子でした。
(拙いコードを読解いただきありがとうございました……)

なお、全ての問題に対処出来たと思われるチームでも、
「解答で一部修正に言及していない」「デバッグ用のalertが削除されないまま残っている」
「解答では修正されているはずのファイルがサーバ上では修正されていない」等の理由で減点が発生しました。
また、STEP1/2の解決のみで問題クリアと判断した様子のチームも見受けられました。

いずれも解答提出前後の見直しで防げる内容となりますので、
今一度落ち着いて問題文と解答、修正後のサービス状況を確認いただければと思います。

 /

皆さんはLDAPを使ったことがありますか?
LDAPとは Lightweight Directory Access Protocol の略で、ディレクトリサービスのためのプロトコルです。ディレクトリサービスというのはその名前の通りディレクトリのようなツリー状の情報を管理するサービスで、その特徴からリソースの場所・設定などの情報を一元管理するのに用いられています。
とりわけ大きな組織においてはこのような情報を管理することにおいて大きなメリットがあります。管理すべき機器やユーザの数が膨大になってきたとき、必要に応じて素早く情報にアクセスできることが要求されるためです。
多くの企業ではディレクトリサービスを用いて情報を管理しており、ディレクトリサービスを提供するためのソフトウェアとしてはActive Directoryが代表的です。優れたGUIと長年Microsoftがメンテナンスしてきたことによる実績があり、何よりWindowsマシンを管理するのに長けているからです。
一方で、昔ながらのOpenLDAPを用いる場合もあります。OpenLDAPはLDAPのオープンソース実装の一つであり、高機能なActive Directoryなどに比べて軽量・高速に動作します。先に挙げたActive DirectoryもLDAPをベースに実装されており、LDAPへの理解を深めることはディレクトリサービスを知る上で欠かせないと言えます。
今回出題したトラブルは、LDAPに関する基本的な操作・知識を問う問題でした。LDAPを運用した経験がないと時間内に解くのはなかなか難しかったのではないかと思います。

問題文

最近LDAPを導入して、GitLabの認証とサーバへのSSHをLDAP経由で行えるように設定したのだがどうもうまく動いていないようだ。 原因を突き止めて、GitLabにログインできるようにしてほしい。必要に応じてサーバの設定を変えても構わないが、なるべくセキュリティレベルが下がらないようにしてほしい。また、admin (cn=admin,dc=finals,dc=ictsc) とoperator (cn=operator,ou=users,dc=finals,dc=ictsc) のパスワードが脆弱なので、可能であれば指定したものに変更してほしい。
なお、すべてのサーバはLDAPに登録してあるSSH鍵で入れるようになっているはずだが、こちらも同様に動いてないのでLDAPサーバのみにログインできるアカウントとLDAPに登録したSSH秘密鍵を用意した。必要に応じて使ってほしい。

問題のゴール状態

GitLabにLDAPユーザのoperator (パスワード変更済み) でログインに成功する

解説

今回の問題に登場するサーバは全部で2つです。

  • LDAPサーバ (dc.finals.ictsc)
  • GitLabサーバ (gitlab.finals.ictsc)

それぞれにSSSDがインストールされており、sss_ssh_authorizedkeys コマンドを用いてLDAPに登録されている公開鍵でログインできるようになっているはずが、SSHログインできなくなっている状態がスタートです。
また、GitLabのLDAPログインも同様に動かない (SSL errorが発生する) ので、これらを直してほしいという内容です。

初期状態をまとめると以下の図のようになります。

図で×印がついている部分がLDAPで認証を行う部分であり、これらをすべて復旧するのがゴールです。

問題のゴール自体はGitLabでLDAPログインできるようになることとなっていますが、実際には認証を直してGitLabサーバへのSSHログインができるようにならないとトラブルシューティングが行えないため、まずはSSHログインを復活させるのが1つ目のゴールになります。

SSL証明書の有効化

GitLabでLDAPログインを試みると以下のようなエラーメッセージが出ます。

Could not authenticate you from Ldapmain because “Ssl connect syscall returned=5 errno=0 state=sslv2/v3 read server hello a”.

これはそもそもSSL接続が確立できていないということなので、opensslで確認してみます。

~$ openssl s_client -connect 192.168.2.10:636
CONNECTED(00000003)
140350580467344:error:140790E5:SSL routines:SSL23_WRITE:ssl handshake failure:s23_lib.c:177:
---
no peer certificate available
---
No client certificate CA names sent
---
SSL handshake has read 0 bytes and written 289 bytes
---
New, (NONE), Cipher is (NONE)
Secure Renegotiation IS NOT supported
Compression: NONE
Expansion: NONE
SSL-Session:
Protocol  : TLSv1.2
Cipher    : 0000
Session-ID:
Session-ID-ctx:
Master-Key:
Key-Arg   : None
PSK identity: None
PSK identity hint: None
SRP username: None
Start Time: 1552100789
Timeout   : 300 (sec)
Verify return code: 0 (ok)
---

これを見ればわかるとおり、そもそも証明書が設定されていないことがわかります。
しかし /etc/ldap/slapd.conf を見ると TLSCACertificateFile=/etc/ldap/ssl/ca.crt などの設定がされているため本来は有効化されているはずですが、これらのパラメータは少なくともdebian系のslapdでは正しく動きません。よって適切なLDIFを書いて、手動で有効化してやる必要があります。

dn: cn=config
changetype: modify
add: olcTLSCACertificateFile
olcTLSCACertificateFile: /etc/ldap/ssl/ca.crt
-
add: olcTLSCertificateKeyFile
olcTLSCertificateKeyFile: /etc/ldap/ssl/dc.finals.ictsc.key
-
add: olcTLSCertficateFile
olcTLSCertficateFile: /etc/ldap/ssl/dc.finals.ictsc.crt

SSL証明書のパーミッションも正しくしておきましょう。

~$ ls -lhat /etc/ldap/ssl/
total 24K
drwxr-xr-x 2 root root 4.0K Feb 17 02:07 .
-rw-r--r-- 1 root root 2.0K Feb 17 02:07 ca.crt
-rw-r--r-- 1 root root 5.9K Feb 17 02:07 dc.finals.ictsc.crt
-rw------- 1 root root 1.7K Feb 17 02:07 dc.finals.ictsc.key
drwxr-xr-x 7 root root 4.0K Feb 17 02:07 ..


...

~$ ls -lhat /etc/ldap/ssl/
total 24K
drwxr-xr-x 2 root root     4.0K Feb 17 02:07 .
-rw-r--r-- 1 root openldap 2.0K Feb 17 02:07 ca.crt
-rw-r--r-- 1 root openldap 5.9K Feb 17 02:07 dc.finals.ictsc.crt
-rw-r----- 1 root openldap 1.7K Feb 17 02:07 dc.finals.ictsc.key
drwxr-xr-x 7 root root     4.0K Feb 17 02:07 ..

先ほどのLDIFを olcSSL.ldif という名前で保存し、以下のコマンドで適用します。
sudo ldapadd -Y EXTERNAL -H ldapi:/// -f olcSSL.ldif

SSSDで公開鍵が取得できない

サーバの/etc/ssh/sshd_configを見ると、AuthorizedKeysCommandディレクティブにsss_ssh_authorizedkeysが設定されていることがわかります。しかし試しにこれを実行してみても公開鍵が出力される様子がありません。

~$ /usr/bin/sss_ssh_authorizedkeys operator
~$

これが原因でSSHログインができないことがわかりますが、なぜ公開鍵が取得できないのでしょうか。LDAPの設定を見直しても公開鍵はちゃんと登録されています。

sss_ssh_authorizedkeysコマンドには実はデバッグオプションが存在します(これはhelpでも出てきません)。--debug 10という引数をつけてコマンドを呼び出すと、詳しいエラーメッセージを見ることができます。

~$ ~$ sss_ssh_authorizedkeys operator --debug 10
(Wed Mar 27 20:25:46:414921 2019) [sss_ssh_authorizedkeys] [main] (0x0040): sss_ssh_format_pubkey() failed (22): Invalid argument

このメッセージから、登録されている公開鍵のフォーマットが正しくないと予想できます。もう一度LDAPに登録されている鍵をみてみましょう。

~$ ldapvi -D 'cn=admin,dc=finals,dc=ictsc' -b 'dc=finals,dc=ictsc'
...
loginShell: /bin/bash
sshPublicKey:; ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJodlzzbHLCCfldHfG7xKlA4tl6t118hAdjbzuZIYCJELLFTwctlFVOBgZHs4JkT5Cgm7eK1VXL99w7SapNzhMs= operator@dc^M\

よく注意して見ると、公開鍵の末尾に改行が含まれていることがわかります。試しに改行を消してみるとどうなるでしょうか。

~$ sss_ssh_authorizedkeys operator
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJodlzzbHLCCfldHfG7xKlA4tl6t118hAdjbzuZIYCJELLFTwctlFVOBgZHs4JkT5Cgm7eK1VXL99w7SapNzhMs= operator@dc

ちゃんと取得することができました。秘密鍵でSSHログインが可能なことも確認できます。

~$ ssh -i files/id_ecdsa operator@192.168.2.10
Last login: Sun Feb 17 03:38:42 2019 from 192.168.2.1
Could not chdir to home directory /home/operator: No such file or directory
operator@dc:/$

これで登録してある公開鍵でログインができるようになりました。

パスワードの変更

GitLabのトラブルシューティングに移る前にパスワードの変更をしておきます。

ldapviを用いてパスワードを変更しようとするとエラーが発生し変更できなかったり、phpLDAPadminにおいてはエラーすら出なかったりします。(この場合も依然として変更はできていません)

このような場合はアクセスコントロールを疑います。/etc/ldap/ldif/olcAccess.ldifが置いてあるのでこの中身をみてみましょう。

dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcAccess
# password should not be readable and only user can update
olcAccess: to attrs=userPassword
by self =sw
# uid, uidNumber, gidNumber should not be updated
olcAccess: to attrs=uid,uidNumber,gidNumber
by self read
by users read
# for ssh login
olcAccess: to attrs=sshPublicKey
by self write
by group.exact="cn=servers,ou=sgroups,dc=finals,dc=ictsc" read
# for sudo
olcAccess: to dn.children="ou=sudoers,dc=finals,dc=ictsc"
by group.exact="cn=servers,ou=sgroups,dc=finals,dc=ictsc" read
# not matched
olcAccess: to *
by self read
by users read
by anonymous auth
by * none

注目すべきはuserPasswordに対して設定してある項目です。by self =swはsearchとwriteをユーザー自身に許可する設定なので大丈夫なはずですが、by anonymous authがついていないのでbindができないようになっています。

よってolcAccess.ldifuserPasswordby anonymous authを追加してあげるとパスワードの変更が可能になります。

GitLabの証明書

SSSDのエラーを修正したことでGitLabにログインが可能になりますが、GitLabのログインページは依然としてエラーを出します。エラーメッセージを読むと証明書周りのエラーということがわかるので、/etc/gitlab/gitlab.rbの中身を確認します。

LDAPの設定部分にverify_certificates: trueとあるので、証明書がチェックに引っかかっていることが原因のようです。これを回避するためには証明書のチェックを無効にするのが一つの手ですが、セキュリティの観点からはあまり好ましい対策とは言えません。証明書 (dc.finals.ictsc) をシステムのチェーンに登録して有効な証明書とするのがベターです。

~$ sudo -s
~# cd /usr/share/ca-certificates/
~# openssl s_client -showcerts -connect dc.finals.ictsc:636 2&amp;amp;amp;amp;amp;gt;/dev/null | openssl x509 &amp;amp;amp;amp;amp;gt; rootCA.crt
~# echo "rootCA.crt" &amp;amp;amp;amp;amp;gt;&amp;amp;amp;amp;amp;gt; /etc/ca-certificates.conf
~# update-ca-certificates

GitLab上のユーザブロック解除

証明書のエラーを解決するとようやくLDAP認証が動くようになります。しかしoperatorユーザはなぜかログインできず、ブロックされている旨のメッセージが出ます。

これはすでにローカルでoperatorという名前のユーザが登録されていたことが原因ですが、解除するためにはadminアカウントが必要になります。operatorユーザでSSHログインするとsudoでrootになることができるので、GitLabのrails consoleに直接接続してパスワードを書き換えることができます。

~# gitlab-rails console production
user = User.where(id: 1).first
user.password = 'strongpassword'
user.password_confimation = 'strongpassword'
user.save!

これでadminとしてログインして、admin areaにあるユーザの設定でLDAPのoperatorユーザを有効にするとログインできるようになります。

採点基準

GitLabサーバにログインできるようになるのが一つ目のゴールなので、ここまで到達した場合は満点の50%が得られます。

  • LDAPユーザのパスワードを変更する (20%)
  • GitLabサーバにログインできるようになる (30%)
  • GitLabでLDAP認証を使えるようにする
    • LDAPをTLS通信に対応させる (30%)
    • operatorユーザのブロックを解除する (20%)

講評

今回の問題の一番のポイントはLDAPそのもではなく、Undocumentedな仕様を適切に探し出すことができるかという点にありました。OpenLDAPのslapd.confが意図した通りに動かない、SSSDが解釈できる公開鍵のフォーマットに隠された仕様があるなど、ソフトウェアは思わぬところで予想に反する挙動を示すことがあります。事前に知識を持っている場合はすぐに気づくことができますが、多くの場合はそうではありません。今回はLDAP+SSSD+GitLabという、意外と一般的だが普段は使ってなさそうな技術を意図的に選びました。

もっとも高得点を獲得したのはLDAPのSSL証明書の設定を解決したチームでした。残念ながらGitLabサーバへSSHできるところまでたどり着くチームはいませんでしたが、コンテスト時間がこれだけ短く、問題数が多いことを考えると仕方ないのかなと思います。また、得点の高さをみて手をつけなかったチームも結構いたのではないでしょうか。トラコンでは必ずしも問題を解ききる必要はないので、部分点解法を大量に稼いでいくのも一つの戦略です。