もなかアイスの試食品

「とりあえずやってみたい」そんな気持ちが先走りすぎて挫折が多い私のメモ書きみたいなものです.

SpringBootで暗号化・復号処理の実装

はじめに

DBにデータを保存する際に、セキュリティ向上のため暗号化してDBに保存することにした。

目次

参考サイト

qiita.com

blog.hide92795.org

実装

設定値の追加

application.ymlに暗号化に使用するキーの設定値を追加した

my_constants:
  security:
    key: (英数大文字小文字・数字・記号を使った16文字)

キーは16文字で、正直決めるのが面倒くさかったので、以下のサイトでランダム生成した

www.luft.co.jp

Beanの追加

暗号化・復号処理で使うインスタンスをBeanで用意する

@Configuration
public class CommonConfig {

    @Bean
    public SecretKeySpec secretKeySpec(@Value("${my-constants.security.key}") String key) {
        return new SecretKeySpec(key.getBytes(), "AES");
    }

    @Bean
    public Cipher cipher()
        throws NoSuchPaddingException, NoSuchAlgorithmException {
        return Cipher.getInstance("AES/CBC/PKCS5Padding");
    }
}

ログ出力

ログ出力にデフォルト実装を使った

/**
 * ログを出力する機能を実装する
 *
 * ログを出力したいクラスで、このインターフェースを実装する<br/>
 *
 * <pre>
 *     class Foo implements Loggable {
 *         public void bar() {
 *             logger().debug(&quot;Hello world!!!&quot;);
 *         }
 *     }
 * </pre>
 */
public interface Loggable {

    /**
     * デフォルト実装
     *
     * @return ロガー
     */
    default Logger logger() {
        return LoggerFactory.getLogger(this.getClass());
    }

}

暗号化・復号処理クラスの作成

実際の暗号化・復号化処理は以下の通り

@Component
public class Crypt implements Loggable {

    private final SecretKeySpec secretKeySpec;

    private final Cipher cipher;

    // 暗号化・復号処理が同時に呼び出されたときに、Cipher#initを実行しないようにするためのロック
    // 実際に同時にCipher#initした時にどうなるかは未確認。良くないことが起きそう気がするのでロックしている
    // cipherを暗号化用/復号用でインスタンスを分けるのもアリかも
    private final Object lockObj = new Object();

    @Autowired
    public Crypt(SecretKeySpec secretKeySpec, Cipher cipher) {
        this.secretKeySpec = secretKeySpec;
        this.cipher = cipher;
    }

    /**
     * 暗号化する
     *
     * @param str 暗号化する文字列
     * @return 暗号化した文字列
     */
    public String encrypt(String str) {
        synchronized (this.lockObj) {
            this.setEncryptMode();
            byte[] encodedBytes;
            try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
                // 最初に初期ベクトルを出力
                outputStream.write(this.cipher.getIV());
                // ByteArrayOutputStream#toByteArray実行前に、CipherOutputStreamを先にcloseしないと復号化に失敗する
                try (CipherOutputStream cipherOutputStream = new CipherOutputStream(outputStream, this.cipher)) {
                    cipherOutputStream.write(str.getBytes());
                }
                encodedBytes = outputStream.toByteArray();
            } catch (IOException e) {
                this.logger().error("failed cipher.");
                throw new CryptException(e);
            }

            byte[] base64Bytes = Base64.getEncoder().encode(encodedBytes);
            return new String(base64Bytes);
        }
    }

    /**
     * 復号する
     *
     * @param str 復号する文字列
     * @return 復号した文字列
     */
    public String decrypt(String str) {
        synchronized (this.lockObj) {
            byte[] base64Bytes = Base64.getDecoder().decode(str);
            try (ByteArrayInputStream inputStream = new ByteArrayInputStream(base64Bytes)) {
                byte[] iv = getIVForDecode(inputStream);
                this.setDecryptMode(iv);
                return this.decryptCipherStream(inputStream);
            } catch (IOException e) {
                this.logger().error("failed decode.");
                throw new CryptException(e);
            }
        }
    }

    /**
     * 暗号化モードに設定する
     */
    private void setEncryptMode() {
        try {
            this.cipher.init(Cipher.ENCRYPT_MODE, this.secretKeySpec);
        } catch (InvalidKeyException e) {
            this.logger().error("failed initialize cipher.");
            throw new CryptException(e);
        }
    }

    /**
     * 復号モードに設定する
     */
    private void setDecryptMode(byte[] iv) {
        IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
        try {
            this.cipher.init(Cipher.DECRYPT_MODE, this.secretKeySpec, ivParameterSpec);
        } catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
            this.logger().error("failed initialize cipher.");
            throw new CryptException(e);
        }
    }

    /**
     * 暗号化されたストリームを復号する
     *
     * @param inputStream ストリーム
     * @return 復号されたデータ
     */
    private String decryptCipherStream(ByteArrayInputStream inputStream) {
        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
            try (CipherInputStream cipherInputStream = new CipherInputStream(inputStream, this.cipher)) {
                byte[] buffer = new byte[1024];
                int length;
                while ((length = cipherInputStream.read(buffer)) != -1) {
                    outputStream.write(buffer, 0, length);
                }
            }
            return outputStream.toString();
        } catch (IOException e) {
            this.logger().error("failed decode.");
            throw new CryptException(e);
        }
    }

    /**
     * 暗号化されたデータから初期ベクトルを取得する。
     *
     * @param inputStream 暗号化データのストリーム
     * @return 初期ベクトル
     */
    private static byte[] getIVForDecode(ByteArrayInputStream inputStream) {
        byte[] iv = new byte[16];
        for (int i = 0; i < iv.length; ++i) {
            iv[i] = (byte)inputStream.read();
        }
        return iv;
    }

}

例外クラス

暗号化・復号失敗時の例外クラス

@ResponseStatus(code = HttpStatus.INTERNAL_SERVER_ERROR)
public class CryptException extends RuntimeException {

    public CryptException() {
    }

    public CryptException(String message) {
        super(message);
    }

    public CryptException(String message, Throwable cause) {
        super(message, cause);
    }

    public CryptException(Throwable cause) {
        super(cause);
    }

    public CryptException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

テスト

適当にテストコードを書いてみた。

10万回ほどランダムな文字列を作成して、暗号化→復号して文字列が一致するかテスト

何回か実行して大丈夫だった。

@RunWith(SpringRunner.class)
@SpringBootTest
public class CryptTests implements Loggable {

    @Autowired
    private Crypt testTarget;

    @Test
    public void cryptTest() {
        IntStream.rangeClosed(1, 100_000)
            .forEach(i -> {
                int randomNumber = Integer.parseInt(RandomStringUtils.randomNumeric(1, 5));
                String rawText = RandomStringUtils.randomAlphanumeric(randomNumber);
                this.logger().info(String.format("rawText text:%s", rawText));

                String encodedText = this.testTarget.encrypt(rawText);
                this.logger().info(String.format("encoded text:%s", encodedText));

                String decodedText = this.testTarget.decrypt(encodedText);
                assertEquals(rawText, decodedText);
            });
    }

}

おわりに

取り敢えず暗号化する処理はできた。

暗号化するとデータが増加するので、暗号化前のデータを圧縮した方が良いと何処かで見かけたので、ZipOutputStreamとかGZIPOutputStreamを使って見たけど、上手く行かなかった・・・

PythonでAWS EC2を起動・停止する

はじめに

ブラウザを開いてAWSコンソール画面にログインして、EC2を起動・停止するのが面倒くさい。

なので、プログラム(Python)で起動するようにしてみた。

環境

ソースコード

boto3のドキュメントを参考にしつつ作成

boto3.amazonaws.com

EC2を起動・停止するメイン処理はこんな感じになった

ファイル名は「aws.py」で作成

# -*- coding: utf-8 -*-

import boto3
import json
import logger
from exception import NotSupportError, NotFoundResource
from botocore.exceptions import ClientError

with open('credentials.json', 'r') as stream:
    credentials = json.load(stream)

class EC2:
    """
    EC2を起動・停止する機能を提供する
    """

    def __init__(self, instance_id, region_name):
        """
        コンストラクタ
        :param instance_id: EC2のインスタンスID
        :param region_name: リージョン名
        """
        self.__log = logger.Logger('EC2')
        self.__instance_id = instance_id
        self.__local_session = boto3.session.Session(
            aws_access_key_id=credentials['AccessKey'],
            aws_secret_access_key=credentials['SecretAccessKey'],
            region_name=region_name
        )

    def start(self):
        """
        起動する
        """
        ec2 = self.__create_ec2()
        try:
            state_name = ec2.state['Name']
        except AttributeError as e:
            # AttributeErrorは、昔あったインスタンスIDを使うと、(Pythonの)インスタンスは生成されるが、
            # 属性stateにアクセスできず例外が発生する
            self.__log.error(e)
            raise NotFoundResource(e)

        self.__log.debug('state is %s' % (state_name,))
        if state_name == 'running':
            self.__log.info('ec2(%s) is already started.' % (self.__instance_id,))
            return
        elif state_name in {'pending', 'shutting-down', 'terminated', 'stopping'}:
            raise NotSupportError('ec2(%s) is %s.' % (self.__instance_id, state_name))
        elif state_name == 'stopped':
            self.__log.info('try start ec2(%s).' % (self.__instance_id,))
            ec2.start()
            # runningになるまで待機
            ec2.wait_until_running()
            self.__log.info('ec2(%s) is started.' % (self.__instance_id,))
            pass
        else:
            raise NotSupportError('Unknown status: %s' % (state_name,))

    def stop(self):
        """
        停止する
        """
        ec2 = self.__create_ec2()
        try:
            state_name = ec2.state['Name']
        except AttributeError as e:
            # AttributeErrorは、昔あったインスタンスIDを使うと、(Pythonの)インスタンスは生成されるが、
            # 属性stateにアクセスできず例外が発生する
            self.__log.error(e)
            raise NotFoundResource(e)

        self.__log.debug('state is %s' % (state_name,))
        if state_name == 'stopped':
            self.__log.info('ec2(%s) is already stopped.' % (self.__instance_id,))
            return
        elif state_name in {'pending', 'shutting-down', 'terminated', 'stopping'}:
            raise NotSupportError('ec2(%s) is %s.' % (self.__instance_id, state_name))
        elif state_name == 'running':
            self.__log.info('try stop ec2(%s).' % (self.__instance_id,))
            ec2.stop()
            self.__log.info('ec2(%s) is stopping.' % (self.__instance_id,))
            pass
        else:
            raise NotSupportError('Unknown status: %s' % (state_name,))

    def __create_ec2(self):
        """
        EC2を操作するオブジェクトを生成する
        :return: EC2を操作するオブジェクト
        """
        self.__log.debug('ec2: %s' % (self.__instance_id,))
        ec2_resource = self.__local_session.resource('ec2')
        try:
            return ec2_resource.Instance(self.__instance_id)
        except ClientError as e:
            self.__log.error(e)
            raise NotFoundResource(e)

credentials.jsonは、AWS IAMで作成した認証情報を保存している。

ちなみに、ログを出力しているクラスの定義はコチラ(ファイル名:logger.py)

# -*- coding: utf-8 -*-

from logging import Formatter, handlers, StreamHandler, getLogger, INFO


class Logger:
    def __init__(self, name=__name__):
        self.logger = getLogger(name)
        self.logger.setLevel(INFO)
        formatter = Formatter("[%(asctime)s] [%(process)d] [%(name)s] [%(levelname)s] %(message)s")

        # stdout
        handler = StreamHandler()
        handler.setLevel(INFO)
        handler.setFormatter(formatter)
        self.logger.addHandler(handler)

    def debug(self, msg):
        self.logger.debug(msg)

    def info(self, msg):
        self.logger.info(msg)

    def warn(self, msg):
        self.logger.warning(msg)

    def error(self, msg):
        self.logger.error(msg)

    def critical(self, msg):
        self.logger.critical(msg)

参考にしたサイト

qiita.com

例外の定義はコチラ(ファイル名:exception.py)

# -*- coding: utf-8 -*-


class NotSupportError(Exception):
    """
    サポートしていない動作が行われようとしたときに発生
    """
    pass


class NotFoundResource(Exception):
    """
    リソースが見つからないときに発生
    """
    pass

使い方

例えば、以下のようなJsonファイルがあった場合

{
    "EC2": [
      {
        "InstanceId": "i-xxxxxxxxxxxxxxx",
        "RegionName": "ap-northeast-1"
      }
    ]
}

以下のようにするとEC2が起動出来る

# -*- coding: utf-8 -*-
import aws

# jsonの読み込み処理(省略)


def start_ec2(ec2_machines):
    if ec2_machines is None or len(ec2_machines) == 0:
        return
    for machine in ec2_machines:
        ec2 = aws.EC2(
            instance_id=machine['InstanceId'],
            region_name=machine['RegionName']
        )
        ec2.start()

if __name__ == '__main__':
    start_ec2(json['EC2'])

停止は、EC2クラスのstopメソッドを呼ぶようにする

おわりに

今回のプログラムはサービス全体(EC2やらロードバランサーやら)を起動・停止するのに使った一部を抜粋

Pythonロードバランサーの生成・削除、Route53の登録も出来たので、それらについてはまたいつか・・・

CentOS7で開発環境用のDNSサーバを構築した話

はじめに

とあるサービスを作っている最中に負荷について調べていたところ、将来的にDBの負荷分散が必要になる可能性があった。

サービスはAWS上に構築するため、DBの負荷分散にはリードレプリカを利用し、DBのアクセスを振り分ける感じになる。

DBの負荷分散を想定してアプリを制作していなかったので、アプリの改造か必要かどうかの確認や改造後の動作確認が必要になり、DBアクセスをロードバランスする(またはそれっぽい)仕組みを開発環境に構築することにした。

マスターDBとリードレプリカなどのDBへのアクセスの振り分けについては、以下の方法があると思う。

  1. TCPのロードバランサ
  2. DNSを使ったなんちゃってロードバランサ(DNSラウンドロビン
  3. Pgpool-Ⅱを使う(DBがPostgreSQLのとき)

Pgpool-Ⅱを使う場合、Pgpool-Ⅱの置き場所をドコにしたら良いのか?EC2を1台増やさないといけないのでは?っと思ったので、あまり調べもせずに除外

最初にNICを2つ持ったCentOS7仮想マシンを用意して、ロードバランサを構築しようとしたけれども、上手く行かなかったので断念

ということもあって、本番環境ではRoute53にマスターDBとリードレプリカのレコードセットを用意することにして、開発環境にはRoute53を同じ動作をしてもらうDNSサーバを構築することにした。

ちなみにDNSラウンドロビン方式だと、Webアプリケーション(SpringBoot)も接続先を切り替える改造が必要で、それについては別の記事を参照

monakaice88.hatenablog.com

目次

想定している本番環境

想定している本番環境は以下の感じ。

f:id:monakaice88:20180728164600p:plain

この中のRoute53の役割と同じような動作をするDNSサーバを構築するのが目的

バージョン・IPアドレス

  • Virtual Box 5.2.28
  • Vagrant 2.2.4
  • CentOS 7
  • bind 9.9.4

  • IPアドレス・名前

    • マスターDB
      • 192.168.35.10
      • DNS名:updatable-db.sample.internal
    • スレーブDB
      • 192.168.35.11
      • DNS名:read-only-db.sample.internal
    • DNSサーバ
      • 192.168.35.20

bindのインストール

まずCentOS7にbindをインストールする。

sudo yum update -y
sudo yum install -y bind bind-utils

bindの設定ファイルの修正

設定ファイル「/etc/named.conf」を修正する。

差分のみ記載

options {
    // ListenするIPを修正
    listen-on port 53 { 127.0.0.1; 192.168.35.0/24; };

    // 問い合わせを許可するIP・ホストを修正
    allow-query     { localhost; 192.168.35.0/24; };

    // dnssecの機能を無効。(なんで無効にしたか覚えていない・・・)
    // 参考にしたサイトの設定をそのまま持ってきたかも
    // 有効のままでも問題ないかも
    dnssec-enable no;
    dnssec-validation no;

    // 追加。構築したDNSで名前解決できない場合は、本来使用しているDNSに転送
    forwarders {
        192.168.xxx.xxx;
        xxx.xxx.xxx.xxx;
    };
    forward only;
};

// 正引き
zone "sample.internal" IN {
    type master;
    file "sample.internal.zone";
};

// 逆引き
zone "35.168.192.in-addr.arpa" {
    type master;
    file "35.168.192.in-addr.arpa.rev";
};

正引き・逆引き設定ファイルの作成

正引き設定設定を「/var/named/sample.internal.zone」に記載する。

$TTL    3600

@       IN      SOA     sample.internal. root.sample.internal.    (
        2018061001
        3600
        900
        604800
        86400
)

                        IN      NS      ns.sample.internal.
ns                      IN      A       192.168.35.20
read-only-db            IN      A       192.168.35.11
updatable-db            IN      A       192.168.35.10

逆引き設定を「/var/named/35.168.192.in-addr.arpa.rev」に記載する。

$TTL 3600

@                IN      SOA sample.internal. root.sample.internal. (
                         2018061001 ; serial
                         3600       ; refresh 1hr
                         900        ; retry 15min
                         604800     ; expire 1w
                         86400      ; min 24hr
)

                 IN      NS     ns.sample.internal.
20               IN      PTR    ns.sample.internal.
11               IN      PTR    read-only-db.sample.internal.
10               IN      PTR    updatable-db.sample.internal.

作成した設定ファイルの所有者を変更する。

sudo chown named:named /var/named/sample.internal.zone
sudo chown named:named /var/named/35.168.192.in-addr.arpa.rev

NetworkManagerの設定の変更

リソルバを固定するため、「/etc/NetworkManager/NetworkManager.conf」の26行目あたりにあるDNSの設定を変更する。

[main]
#plugins=ifcfg-rh,ibft
dns=none

変更後は、NetworkManagerを再起動する。

sudo systemctl restart NetworkManager

「/etc/resolv.conf」の設定を変更する。

nameserver 192.168.35.20

おわりに

本番環境のRoute53と同じ動作をするための開発環境用のDNSサーバを構築できた。

初めてのDNSサーバの構築で、DNSの設定だけではなく、NetworkManagerやらresolv.confを修正しないといけなかったので意外と時間が掛かった気がする。

構築したDNSサーバを使用するときは、PCのDNSサーバの設定を変更する必要あり。

WindowsDNSサーバの変更

support.t-com.ne.jp

MacDNSサーバの変更

pc-karuma.net