0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-04-12

前置き

我々のような受託システム開発会社は、開発依頼を受ける度に、さまざまなドメインにそのシステムをロンチしていきます。
常時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な設定を管理するのに使用

醍醐味

以下、さっそくです。

前提

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

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

levelDB

# 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

# for PHP
cd api/
composer require yosymfony/toml

# for python
sudo pip install toml
pip list | grep toml
    toml 0.10.2
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

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

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

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/',
  }
}
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"
  ]
}
app/src/configs/common.js
export default Object.freeze({
  CNAME: 'SSL証明書 更新状況一覧',
  CURL: 'https://ssl-check.yourdomain.com/',
});
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')
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>
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

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

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

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

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?