LightsailをIPv6オンリーにしたらLet's Encryptで証明書の更新ができなくなってた

先日、LambdaからIPv6でアクセスできるようにする で書いたとおり、IPv4アドレス課金を避けるためにLightsailをIPv6 Onlyのインスタンスにしていました。 LightsailをIPv6/IPv4デュアルスタックからIPv6 Onlyにするには、一度停止した上でスナップショットを作成し、スナップショットから新しいインスタンスを作るだけで簡単にできました。

が、数日後Let's Encryptで証明書が更新できていないことに気がつきます。 確認してみると、Let's Encrypt自体はIPv6で使えるものの、Route53のAPIの呼び出しに失敗していることがわかりました。(実際に route53.amazonaws.com をnslookupで AAAA レコードを引くと No Answerになります)

対応としては、いくつか考えられます。

うちは代々グローバルIPを持たない環境が続いていたため、リモートアクセスの中継拠点としてさくらのVPS上にVPNを構築していました。 LightsailからのIPv4トラフィックは、このVPNを経由してアクセスするように設定することでRoute53 APIを直接呼び出せるようにしました。

IPv6に進んでいくのは良いことなのですが、まだ課題が多そうです。

LambdaからIPv6でアクセスできるようにする

AWSでは各種サービスのグローバルIPv4アドレスが有料化されることになり、EC2などで一部無料枠もあるものの、基本的にはIPアドレスあたり概ね500〜600円程度の課金額がかかるようになりました。

無償版G Suiteが廃止されるのでAWS SESとDockerでメール送受信システムを作ったで作成したシステムでは最終的にLightsailで中継用のIMAP/POP3サーバーを作ることになりましたが、こちらもIPv6オンリーのインスタンスに変更しなければ値上げとなりました。

LightsailをIPv6インスタンスに変更するにあたって、LambdaのコードからIPv6にアクセスが必要となりましたが、そのためにはLambdaにVPCアウトバウンド接続の設定が必要となったので、上記のメール受信システムのアップデートに合わせて行った設定の要点をAWS CDKのコードベースで説明します。

要点

続きを読む

Firebase AuthenticationでLogin with Discordを実現する

Firebase Authenticationでは様々な認証を簡単に導入することができ、GoogleApple、Faccebookといったよく利用されるサービスを統合した認証のほかに、カスタムの認証システムと統合することもできます。

これを利用して、Discordのアカウントを使ってログインするWebサービスを、Firebase HostingとFirebase Functionsを統合して作ってみたいと思います。Discordでは一般的なOAuth 2.0ののフローでサービスにログインすることができますので、これを使ってFirebase Authenticationにユーザーを登録してログインできるようにします。

なお「Identity Platform を使用する Firebase Authentication」というものもあり、こちらはSAMLOpenID Connectなども簡単に設定でき様々な機能を利用でき、SAMLOpenID Connectも簡単に実装できますが、課金額が結構高い[^1]ので、無料枠超えない範囲とわかってる場合やコストが見合うかどうか、急にトラフィックが増大するリスクがあるかどうか等で検討すると良いでしょう。

続きを読む

mkcertを使用してローカルホストにマルチドメインの環境を作る

mkcertを使用するとローカルの開発環境であるlocalhosthttpsにすることができます。[^1] 簡単にやろうとすると、localhosthttpsにすることになりますが、これだと複数のサービスを扱う場合にCookieやLocalStorageが共有され不具合を起こす場合があります。

実際はポート番号をサービスごとにばらしても問題を解決することができますが、覚えるのが面倒。サービスごとにわかりやすいドメインにしたい。

そこで、開発用のローカルを指し示すドメインを用意することで、ローカルで開発中のサービスごとにドメインを付与しつつ、そのサービス用の開発用のhttps証明書をmkcertで作ります。

手順

必要なのは2つです。

  1. 実在するドメイン127.0.0.1 を指し示すワイルドカードサブドメインを作る
  2. ワイルドカード部分に任意の文字を指定した証明書をmkcertで作る

1.は最初の1回だけやってしまえばあとは作業不要です。

続きを読む

M2 MacBook Airに買い換えてIntelから乗り換えたので、最初から環境を再設定した

M2 MacBook Air(M2, 13インチ)に買い換えました。

前使っていたのは2018年モデルのMacBook Pro 15インチでした。 個人的に5年買い換えサイクルにしていたのと、最近ファンが回る場面が多くなりそろそろintelは限界かと思った次第です。

元々アプリエンジニアなので前回はMacBook Pro 15インチにしていたのに、今回そうしなかったのは、M2は強力なので個人でやる開発ならProでなくても十分な性能ではなかろうかと考えたこと、外でStoryboardを操作する機会はなくなったので13インチで十分(必要なら家で4Kモニターつないでやればいい)、あと見た目が好きだったので13インチのMacBook Airとなりました。

IntelからApple Siliconへの乗り換えなので、一度全部設定をやり直そうと、今回はゼロから設定することにしました。 このエントリは設定手順のメモです。

旧端末での準備

続きを読む

アクセスキーを使わずにAWS SESを使って、Firebaseからメール送信できるようにする

GCPやFirebaseにはメール関連の現行サービスがなく、外部のメールサービスを使用する必要があります。 そのため、SendGridやAWS SESなどを組み合わせる必要があります。

今回はAWS SESを使うことになったのですが、そのためにアクセスキーやSMTPパスワードを管理するのはキーの管理方法を考える必要があり、手間です。 ですので、GCPのサービスアカウントでAWSのIAMロールに認証してメールを送信ようにしてみました。

今回の構成

メールを送りたいGCP側のサービスはFirebase Functionsで実装されています。Firebase FunctionsはApp Engineデフォルトサービスアカウントで動作します。

App EngineデフォルトサービスアカウントのIDトークンで、AWS STS(Security Token Service)を呼び出し、AWS側のIAMロールに対応する一時的な認証情報を取得します。

メール送信はnodemailerのSESトランスポートを使用しますので、プログラムからは通常のSMTPとそう変わらない使用感で送信できます。

GCPサービスアカウントの確認と設定

まずは、GCPのサービスアカウントの情報を確認します。 GCPのIAMコンソールで、App Engine デフォルトサービスアカウントの詳細情報を開き「一意のID」を調べます。

次に、サービスアカウントの詳細情報の「権限」タブを開き、「サービスアカウントトークン作成者」権限を付与しておきます。*1

AWS IAMの設定

次に、ターゲットとなるAWSアカウントで、IAMの管理画面を開き、設定を加えていきます。

メール送信のポリシーを作成

メール送信だけの権限があるポリシーを作成します。GCPSendEmailなどといったポリシー名で以下の内容で作成しました。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "SendEmail",
            "Effect": "Allow",
            "Action": [
                "ses:SendEmail",
                "ses:SendRawEmail"
            ],
            "Resource": "arn:aws:ses:*:000000000000:identity/*"
        }
    ]
}

ロールの追加

次に、GCPのサービスアカウントをロールとして追加します。

ロールを作成画面で「ウェブアイデンティティ」を指定し、アイデンティティプロバイダでGoogleを指定、Audienceにサービスアカウントの「一意のID」を指定しておきます。

許可を追加で、先ほど作っておいたポリシー(GCPSendEmail)を指定します。

最後のロールの詳細では、GCPSA_(サービスアカウントアドレス) としておくとよいでしょう。この名前はSTSAPIを呼び出すときに必要になります。

AWSの認証情報を得るプログラムを記述する

それでは、Firebase Function でプログラムを書いていきましょう。

必要となる情報

できるだけ実行環境から設定値を取って、定数を定義せずに済むようにしようと思いますが、定数化しておかなければならない情報は以下の通りです。

Cloud Functionsの環境変数を使って注入しても良いでしょう。

サービスアカウントメールアドレスの取得

gcp-metadata ライブラリを使うことで、実行中のサービスアカウントメールアドレスを取得できます。 ローカルで実行しているときは取得できないため、process.env.FUNCTIONS_EMULATOR が true の場合は定数からサービスアカウントメールアドレスを得るようにします。

import * as gcpMetadata from "gcp-metadata";

const isEmulator = Boolean(process.env.FUNCTIONS_EMULATOR);
const emulatorServiceAccount = process.env.EMULATOR_SA;

const getServiceAccountEmailAsync = async (): Promise<string> => {
  if (isEmulator) {
    return emulatorServiceAccount;
  } else {
    return await gcpMetadata.instance(
      "service-accounts/default/email",
    );
  }
};

サービスアカウントのIDトークン取得

サービスアカウントのIDトークンを取得します。

gcp-metadataライブラリを使って取得することもできますが、ローカル動作を考慮してGCPのIAM Credential APIを使用して取得します。(GCPAPIとサービスコンソールで、IAM Credentials APIの有効化が必要です)

import { IAMCredentialsClient } from "@google-cloud/iam-credentials";

const audience = "https://www.googleapis.com/";

const getServiceAccountIdTokenAsync = async (
  sa: string,
): Promise<string | undefined> => {
  const client = new IAMCredentialsClient();
  const [response] = await client.generateIdToken({
    name: `projects/-/serviceAccounts/${sa}`,
    audience,
  });
  if (!response.token) {
    return undefined;
  }
  return response.token;
};

サービスアカウントのIDトークンでAWSの認証情報を取得する

サービスアカウントのIDトークンを使ってAWSSTS APIを呼び出し、AWSの認証情報を取得します。

import {
  AssumeRoleWithWebIdentityCommand,
  STSClient,
} from "@aws-sdk/client-sts";

const awsAccountId = "000000000000";

type AWSCredentials = {
  accessKeyId: string;
  secretAccessKey: string;
  sessionToken: string;
  expiration?: Date;
};

const getAWSCredentialsByWebIdentityAsync = async (
  sa: string,
  gcpToken: string
): Promise<AWSCredential | undefined> => {
  const roleArn = `arn:aws:iam::${awsAccountId}:role/GCPSA_${sa}`;
  const sts = new STSClient({});
  const result = await sts.send(
    new AssumeRoleWithWebIdentityCommand({
      RoleArn: roleArn,
      WebIdentityToken: gcpToken,
      RoleSessionName: new Date().getTime().toString(),
    }),
  );
  if (!result.Credentials) {
    return undefined;
  }
  const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } =
    result.Credentials;
  if (!AccessKeyId || !SecretAccessKey || !SessionToken) {
    return undefined;
  }
  return {
    accessKeyId: AccessKeyId,
    secretAccessKey: SecretAccessKey,
    sessionToken: SessionToken,
    expiration: Expiration,
  };
};

取得したAWS認証情報を使ってメールを送信する

それでは、取得した認証情報でメールを送信してみましょう。

import * as aws from "@aws-sdk/client-ses";
import { SES } from "@aws-sdk/client-ses";
import * as nodemailer from "nodemailer";

const sendMailAsync = async (): Promiose<void> => {
  const sa = await getServiceAccountEmailAsync();
  const token = await getServiceAccountIdTokenAsync(sa);
  if (!token) {
    return;
  }
  const awsCredentials = await getAWSCredentialsByWebIdentityAsync(sa);
  if (!awsCredentials) {
    return;
  }
  
  const ses = new SES({ credentials });
  const transporter = nodemailer.createTransport({
    SES: { ses, aws },
  });
  await transporter.sendMail({
    from: "from@example.com",
    to: "to@example.com",
    subject: "test mail",
    text: "this is a test mail",
  });
};

*1:より踏み込むのであれば、AWSアクセス専用のサービスアカウントを作り、サービスアカウントの権限借用で必要な場合にのみトークンを生成するようにしても良いでしょう。

別のディスプレイに表示されているウィンドウを強制召還するmacOSアプリを作った

僕はMacBook ProとPS5を同じディスプレイに繋ぎ、ディスプレイの入力をメニューで切り替えて使っている。 MacBook Proのディスプレイもサブディスプレイとして使っており、PS5を起動してディスプレイに表示しているときはMacBook攻略サイトを出したりDiscordを出したりしている。

とても便利に使えているのだが、一つ問題がある。

通常はMacBook Proの出力をディスプレイで表示している。ディスプレイ側にはブラウザを出していて、MacBook側ではDiscordを出していたとする。

f:id:iseebi:20220310012652p:plain

ゲームをするときはディスプレイの入力をPS5に切り替える。

f:id:iseebi:20220310012703p:plain

この状態で攻略サイトを見たくなってブラウザが使いたくなった場合、ディスプレイ側にいるので見えず、これを持ってくるためには一度入力を切り替えなければならず、とてもめんどくさいです…

f:id:iseebi:20220310012752p:plain

というわけで、これをなんとかするためにこういうツールを作りました。

f:id:iseebi:20220310012850p:plain

メニューバーに表示されて、クリックすると現在表示されているウィンドウを一覧表示、選択したウィンドウが現在のディスプレイになければ強制的に現在のディスプレイに移動させ、その上でウィンドウをアクティブにします。

github.com

macOSAPIは使い方を探すのが難しくて大変でした。