Toml & Python & PHP & vue & levelDB でSSL証明書の有効期限を毎日チェック

# 前置き
我々のような受託システム開発会社は、開発依頼を受ける度に、さまざまなドメインにそのシステムをロンチしていきます。
常時SSLが当たり前となった今日において、そのドメインの数だけSSL証明書が必要になります。
そしてSSL証明書にはLet's encryptを採用するケースも多いため、その更新期限管理は定期的なチェックが欠かせません。
（バッチで自動更新は当然としても）

うちではもうなんだかんだと管理している客先ドメインが100超になっているので、日次バッチで全ドメインの有効期限を取得して、これが一覧画面から確認できるようにしておくことで、ある日突然サイトにアクセスできなくなって大慌てで証明書を更新しなけれけばならないような事態に未然対策しています。

# 本題
というのはまあいいとして、ここでの趣旨は、このようなワンオペサービスの裏側で実装される数々の周辺技術を束ねてひとつのアプリケーションとして構築する醍醐味感、のようなものになると思います。
目的はひとつでもそれを実現するための中間過程はいくつも存在し、それぞれを実現するための手段も多岐に及ぶため、適材適所な取捨選択をしていくのであれば応用技術の醍醐味がやってくるのは当然なのです。

# 登場人物
* crontab
スクリプトを定期実行されるためのスケジューラ。実体はシェル
ここではドメインの有効期限を日次バッチで取得するための設定に使用

* levelDB
キーバリューストアな軽量ストレージ。使用頻度の高いものを優先に階層的なインデックスをおこなうのが特徴
ここでは日次バッチで取得した各ドメインの有効期限をFQDN単位で記録するのに使用

* openSSL
SSL証明書等の暗号プロトコルを触るためのデファクトスタンダード
ここでは外部SSL証明書のパースに使用

* python
昔からあるけど最近超花形言語として返り咲いた万能言語（3.7からハッシュが順番を保証してくれるようになったので私も3.7から大好き）
ここではcrontabをトリガーにopenSSLで取得した有効期限をlevelDBに登録するためのバッチスクリプトとして使用

* PHP
昔からあるけど今でも人気なWEB開発言語（私はずっと大好き）
ここではバッチで記録した全ドメインの有効期限を後述のVueにAPIとして提供するのに使用

* Vue
JSフレームワークの雄。SPAや簡単なアプリケーションなら相性抜群
ここではフロントエンドの更新期限一覧画面として使用

* nginx
スクリプトをWEB通信規格を経由して提供するためのミドルウェア
ここではPHPとVueをそれぞれインターフェースするのに使用

* toml
設定ファイルのマークアップ言語。可読性がよく様々な言語にもライブラリが提供されているが、まだダークホース感有
ここでは各言語を横断してDRYな設定を管理するのに使用

# 醍醐味
以下、さっそくです。

### 前提
```sh
pwd
    # 適宜読み替えてください
    /home/you/projects/ssl-check

tree -L 2 -I node_modules
    # 最終的な成果物
	+__ api
	|   +__ composer.json
	|   +__ index.php
	|   L__ vendor
	+__ app
	|   +__ README.md
	|   +__ babel.config.js
	|   +__ dist
	|   +__ package.json
	|   +__ public
	|   +__ src
	|   L__ vue.config.js
	+__ batch
	|   L__ ssl_check.py
	+__ configs
	|   L__ common.toml
	+__ logs
	+__ package.json
	L__ results
	    +__ 000005.ldb
	    +__ 000006.log
	    +__ CURRENT
	    +__ LOCK
	    +__ LOG
	    +__ LOG.old
	    L__ MANIFEST-000004
```

### crontab
```sh
5 4 * * * cd /home/you/projects/ssl-check/batch/ && /usr/local/bin/python3.7 ssl_check.py & > /dev/null 2>&1
```

### levelDB
```sh
# for PHP
sudo apt-get install libleveldb-dev

cd /usr/local/src/
git clone https://github.com/reeze/php-leveldb.git
cd php-leveldb/
phpize
./configure --prefix=/home/you/.phpenv/versions/7.4.0/lib/php/leveldb --with-leveldb=/home/you/.phpenv/versions/7.4.0/include/php/include/leveldb --with-php-config=/home/you/.phpenv/versions/7.4.0/bin/php-config
make
make install
vi ~/.phpenv/versions/7.4.0/etc/php.ini
	extension=/home/you/.phpenv/versions/7.4.0/lib/php/extensions/no-debug-non-zts-20180731/leveldb.so

sudo service php-fpm restart

# for python
sudo pip install plyvel
pip list | grep plyvel
    plyvel 1.3.0
```

### toml
```sh
# for PHP
cd api/
composer require yosymfony/toml

# for python
sudo pip install toml
pip list | grep toml
    toml 0.10.2
```

```toml:configs/common.toml
APP_DIR = "/home/you/projects/ssl-check/"
RESULT_DB = "results"
CHECK_CMD = "openssl s_client -connect {fqdn}:443 -servername {fqdn} </dev/null 2>/dev/null | openssl x509 -text | grep \"Not After\""
STATUS_OK = 1
STATUS_NOTE = 2
STATUS_NG = 3
STATUS_ERROR = 4

# ↓適宜編集
[DOMAIN_LIST]
"example.com" = [
    "example.com",
    "test.example.com",
]
"google.com" = [
    "google.com",
]
"yahoo.co.jp" = [
    "yahoo.co.jp",
]
```

### python
```python:batch/ssl_check.py
import logging
import os
import sys
import logging
import subprocess
import datetime
import calendar
import pytz
import dateutil.parser
import pickle
import click
import plyvel
import json
import toml

configs = toml.load(open('../configs/common.toml'))

logger = logging.getLogger('Batch')
logger.setLevel(10)

fh = logging.FileHandler(configs['APP_DIR'] + 'logs/batch.log')
logger.addHandler(fh)

sh = logging.StreamHandler()
logger.addHandler(sh)

format = logging.Formatter('%(asctime)s - [%(levelname)s] (%(lineno)d) %(message)s')
fh.setFormatter(format)
sh.setFormatter(format)

curr_tz = pytz.timezone('Asia/Tokyo')
curr_ts = int(datetime.datetime.now().timestamp())

results = {};
index = 0;
for brand in configs['DOMAIN_LIST']:
    logger.info(brand + ' >>> start')
    results[brand] = {}
    for fqdn in configs['DOMAIN_LIST'][brand]:
        try:
            limit_at = subprocess.check_output(configs['CHECK_CMD'].format(fqdn = fqdn), shell = True)
            index += 1
            if limit_at != '':
                limit_at = str(limit_at).split(' : ')[1]
                limit_at = limit_at.split('\\n')[0]
                limit_at = dateutil.parser.parse(limit_at)
                limit_ts = calendar.timegm(limit_at.timetuple())

                if limit_ts < curr_ts:
                    status = configs['STATUS_NG']
                elif limit_ts < curr_ts + (60 * 60 * 24 * 30):
                    status = configs['STATUS_NOTE']
                else:
                    status = configs['STATUS_OK']

                limit_at = limit_at.astimezone(curr_tz).replace(tzinfo=curr_tz)
                limit_at = str(limit_at).split('+')[0].replace('-', '/')
                results[brand][fqdn] = {
                    'index': index,
                    'status': status,
                    'limit_at':limit_at
                }
            else:
                logger.warning(fqnd + ' unable to load certificate')
                results[brand][fqdn] = {
                    'index': index,
                    'status': configs['STATUS_ERROR'],
                    'limit_at':None
                }
        except Exception as e:
            logger.warning(e)
            results[brand][fqdn] = {
                'index': index,
                'status': configs['STATUS_ERROR'],
                'limit_at':None
            }
    logger.info(brand + ' <<< end')

logger.debug(results)

try:
    plyvel.destroy_db(configs['APP_DIR'] + configs['RESULT_DB'])
    result_db = plyvel.DB(configs['APP_DIR'] + configs['RESULT_DB'], create_if_missing=True)
    for brand in results:
        for fqdn, result in results[brand].items():
            result_db.put(str(fqdn).encode(), json.dumps(result).encode())
except Exception as e:
    logger.warning(e)
finally:
    result_db.close()
```

### PHP
```php:api/index.php
<?php
require_once 'vendor/autoload.php';
use Yosymfony\Toml\Toml;

$configs = Toml::ParseFile(dirname(__FILE__) . '/../configs/common.toml');

$db = new LevelDB($configs['APP_DIR'] . $configs['RESULT_DB'], ['create_if_missing' => true]);
$results = [];
foreach ($configs['DOMAIN_LIST'] as $brand => $list) {
    isset($results[$brand]) or $results[$brand] = [];
    foreach ($list as $fqdn) {
        $result = $db->get($fqdn);
        $results[$brand][$fqdn] = $result;
    }
}
$checked_at = date('Y/m/d H:i:s', filemtime($configs['APP_DIR'] . $configs['RESULT_DB']));
echo json_encode(['list' => $results, 'checked_at' => $checked_at], JSON_UNESCAPED_UNICODE);
```

### Vue
```vue:app/vue.config.js
module.exports = {
  lintOnSave: false,
  publicPath: 'https://ssl-check.yourdomain.com/',
  devServer: {
    host: 'localhost',
    port: 8030,
    disableHostCheck: true,
    public: 'https://ssl-check.yourdomain.com/',
  }
}
```

```vue:app/package.json
{
  "name": "app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "axios": "^0.21.1",
    "bootstrap": "^4.6.0",
    "bootstrap-vue": "^2.21.2",
    "core-js": "^3.6.5",
    "register-service-worker": "^1.7.2",
    "vue": "^2.6.11",
    "vue-meta": "^2.4.0"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "~4.5.0",
    "@vue/cli-plugin-eslint": "~4.5.0",
    "@vue/cli-service": "~4.5.0",
    "babel-eslint": "^10.1.0",
    "eslint": "^6.7.2",
    "eslint-plugin-vue": "^6.2.2",
    "vue-template-compiler": "^2.6.11"
  },
  "eslintConfig": {
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/essential",
      "eslint:recommended"
    ],
    "parserOptions": {
      "parser": "babel-eslint"
    },
    "rules": {}
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}
```

```vue:app/src/configs/common.js
export default Object.freeze({
  CNAME: 'SSL証明書 更新状況一覧',
  CURL: 'https://ssl-check.yourdomain.com/',
});
```

```vue:app/src/main.js
import Vue from 'vue'
import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'
import App from './App.vue'
import axios from 'axios'
import './registerServiceWorker'
import VueMeta from 'vue-meta'
Vue.use(VueMeta)
Vue.use(BootstrapVue)
Vue.config.productionTip = false
Vue.prototype.$axios = axios

new Vue({
  render: h => h(App),
}).$mount('#app')
```

```vue:app/src/App.vue
<template>
  <div id="app">
    <Ssl/>
  </div>
</template>

<script>
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import Configs from './configs/common'
import Ssl from './components/Ssl.vue'

export default {
  metaInfo: {
    title: Configs.CNAME,
    titleTemplate: Configs.CNAME,
    htmlAttrs: {
      lang: 'ja',
      amp: true
    },
    meta: [
      { charset: 'utf-8' },
      { name: 'application-name', content: Configs.CNAME },
      { name: 'robot', content: 'noindex,nofollow' },
      { name: 'author', content: 'ketoha' },
      { name: 'copyright', content: '&copy;ketoha' },
      { name: 'og:site_name', content: Configs.CNAME },
      { name: 'og:url', content: Configs.CURL },
      { name: 'og:title', content: Configs.CNAME }
    ]
  },
  name: 'App',
  components: {
    Ssl
  }
}
</script>

<style>
@import "../public/index.css";
</style>
```

```Vue:app/src/components/Ssl.vue
<template>
  <div>
    <h1>SSL証明書 更新状況一覧<span>{{ checked_at }}</span></h1>
    <table>
      <thead>
        <tr>
          <th>#</th><th>##</th><th>ドメイン</th><th>FQDN</th><th>ステータス</th><th>期限終了日時</th>
        </tr>
      </thead>
      <tbody>
        <template v-for="(data, brand, pidx) in list">
          <tr v-for="(result, fqdn, sidx) in data">
            <td>{{ pidx+1 }}</td>
            <td>{{ sidx+1 }}</td>
            <td>{{ brand }}</td>
            <td>{{ fqdn }}</td>
            <td v-if="result.status">
              <span v-if="result.status === 1" class="ok">正常</span><span v-if="result.status === 2" class="note">間近</span><span v-if="result.status === 3" class="ng">失効</span><span v-if="result.status === 4" class="error">失敗</span>
            </td><td v-else>-</td>
            <td v-if="result.limit_at">{{ result.limit_at }}</td><td v-else>-</td>
          </tr>
        </template>
      </tbody>
    </table>
  </div>
</template>

<script>
export default {
  name: 'Ssl',
  data: function(){
    return {
        list:{},
        checked_at:null,
        is_error:false
    }
  },
  mounted:function() {
    window.addEventListener('DOMContentLoaded', this.getList)
  },
  methods: {
    getList:function(){
      document.body.classList.add('loading')
      this.$axios.get('https://ssl-check.ketoha.xyz/api/').then(function(response){
        this.list = {}
        this.checked_at = response.data.checked_at
        let list = response.data.list
//console.log(list)
        for (let brand in list) {
          this.list[brand] = {}
          for (let fqdn in list[brand]) {
            this.list[brand][fqdn] = JSON.parse(list[brand][fqdn])
          }
        }
      }.bind(this)).catch(function(error){
        this.is_error = true
      }.bind(this)).finally(function(){
        document.body.classList.remove('loading')
      }.bind(this)
    )}
  }
}
</script>
```

### nginx
```nginx
server {
    listen 80 default;
    server_name ssl-cjeck.yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name ssl-cjeck.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/ssl-check.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ssl-check.yourdomain.com/privkey.pem;

    root /home/you/projects/ssl-check/app/dist;
    index index.html;

    access_log /home/you/projects/ssl-check/logs/access.log;
    error_log /home/you/projects/ssl-check/logs/error.log;

    location / {
        root /home/you/projects/ssl-check/app/dist;
        index index.html;
    }

    location /api/ {
        root /home/you/projects/ssl-check/api;
        index index.php;
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        root /home/you/projects/ssl-check/api;
        index index.php;
        try_files $uri = 404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass   unix:/home/you/.phpenv/versions/7.4.0/var/run/php-fpm.sock;
        fastcgi_index  index.php;
        fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include        fastcgi_params;
    }
}
```

# 最後に

適材適所した結果、毎日このような画面で更新期限管理ができるようになります。
![Screenshot_2021-04-12 SSL証明書 更新状況一覧.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/282332/8a7308f0-b7ea-ea37-57c6-73325d639a02.png)

※画面にはないですが、ここでは有効期限が切れている場合は赤で、一ヶ月を切っている場合はオレンジで表示されるようになっています。
オレンジを検知した場合はメールで通知、とかするともっといいかもしれません。

技術の応用しかしていない身としては、日々OSSでソリューションを提供してくれる先人たちには感謝しかありません。
しっかり活用していきたいものですね。