アクセスキーを使わずに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_(サービスアカウントアドレス) としておくとよいでしょう。この名前はSTSのAPIを呼び出すときに必要になります。
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を使用して取得します。(GCPのAPIとサービスコンソールで、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トークンを使ってAWSのSTS 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", }); };
別のディスプレイに表示されているウィンドウを強制召還するmacOSアプリを作った
僕はMacBook ProとPS5を同じディスプレイに繋ぎ、ディスプレイの入力をメニューで切り替えて使っている。 MacBook Proのディスプレイもサブディスプレイとして使っており、PS5を起動してディスプレイに表示しているときはMacBookに攻略サイトを出したりDiscordを出したりしている。
とても便利に使えているのだが、一つ問題がある。
通常はMacBook Proの出力をディスプレイで表示している。ディスプレイ側にはブラウザを出していて、MacBook側ではDiscordを出していたとする。
ゲームをするときはディスプレイの入力をPS5に切り替える。
この状態で攻略サイトを見たくなってブラウザが使いたくなった場合、ディスプレイ側にいるので見えず、これを持ってくるためには一度入力を切り替えなければならず、とてもめんどくさいです…
というわけで、これをなんとかするためにこういうツールを作りました。
メニューバーに表示されて、クリックすると現在表示されているウィンドウを一覧表示、選択したウィンドウが現在のディスプレイになければ強制的に現在のディスプレイに移動させ、その上でウィンドウをアクティブにします。
macOSで起動中のウィンドウの一覧を列挙する
起動中のウィンドウを一覧するには、CGWindowListCopyWindowInfoを使う。
struct Window: CustomStringConvertible { let rawData: [String: AnyObject] var name: String? { get { return rawData[kCGWindowName as String] as? String } } var number: Int? { get { return rawData[kCGWindowNumber as String] as? Int } } var ownerName: String? { get { return rawData[kCGWindowOwnerName as String] as? String } } var ownerPID: Int? { get { return rawData[kCGWindowOwnerPID as String] as? Int } } var isOnScreen: Bool { get { return rawData[kCGWindowIsOnscreen as String] as? Bool ?? false } } var windowLayer: Int { get { return rawData[kCGWindowLayer as String] as? Int ?? 0 } } var description: String { get { return "Window: \"\(name ?? "nil")\" (\(number ?? -1)) Owner: \"\(ownerName ?? "nil")\" (\(ownerPID ?? -1))" } } } func enumerateWindows() -> [Window] { let options = CGWindowListOption(arrayLiteral: CGWindowListOption.optionAll) guard let results = CGWindowListCopyWindowInfo(options, CGWindowID(0)), let windowList = results as NSArray? as? [[String: AnyObject]] else { return [] } return windowList.map { Window(rawData: $0) } } let windows = enumerateWindows() for window in windows { // isOnScreen かつ windowLayer == 0 のものが、通常のアプリのウィンドウであると判断する if window.isOnScreen && window.windowLayer == 0 { print(window) } }
ただし、このままだとウィンドウのタイトルがほとんど取れない。これはプライバシーの観点で取れないようになっている。
取得するためには画面収録のパーミッションが必要で、以下のコードを実行することでパーミッションを得られる。
CGRequestScreenCaptureAccess()
参考
無償版G Suiteが廃止されるのでAWS SESとDockerでメール送受信システムを作った
20日木曜、大きなニュースが飛び込んできました。2012年に提供が終了して、そのまま9年近く利用できていた旧無償版G Suiteが7月1日に廃止されてしまいます。
自分も黎明の頃から勉強会のメールアドレスや、友人に独自ドメインアドレスを提供したりと、便利に使わせてもらってきていたのですが、現状の利用規模的に個人ユースで月数千円の追加支出になるのはさすがに許容できません。既にGoogle OneやYoutube PremiumなどGoogleにはかなりお金を払っているので、これ以上Googleにお金取られるのは気分的にヤダというのもあります…。
そこでまじめに検討した結果、独自にメール受信システムを作ることにしました。
結果、月々100円以内におさまるシステムに仕上がりました。
2022/05/05(Thr) 追記
本記事では当初Oracle Cloud Infrastructureを使って使用していましたが、課金状態にあるにも関わらず突如理由もなくアカウントを停止されるという事態が発生しているとのことです。
幸い僕の手元の環境は問題ない状態でしたが、このような状況では安心して使いつづけられませんので、OCIのコンピュートで建てていたVMをAWS Lightsailに移しました。 それに伴い本記事は改題しておりますが、中の説明は引き続きOCIとなっています。
これにより、3.5ドル/月が加算され、トータルでは500円程度の維持費となりそうです。
要件検討
まず、要件を洗い出しました。
続きを読むApple USB-C Digital AV Multiport アダプタはアップデートされている
標題の通りなのですが、Apple USB-C Digital AV Multiport アダプタは、Apple純正のUSB-CからUSB-A、HDMI、USB-Cの充電アダプタに変換してくれるアダプタです。MacBookユーザーなら1本は持っているはずです。
僕はMacBook Pro (13-inch, 2016, Four Thunderbolt 3 ports)を買ったときに一緒に買ったアダプタを一緒に使いつづけていましたが、この頃に買ったアダプタをお持ちの方でまだずっと使いつづけている方は買い替えを検討してみてもいいかもしれません。
続きを読むAutomatorアプリケーションでシェルスクリプト+リソースを配りたい
macOS用のツールアプリケーションをインストールする方法がシェルスクリプトだった。ITエンジニア向けだったら良いんですけど、そうでない人が対象だったのでさすがにGUIが必要で、Automatorで手軽にできないかと思って調べてみました。
実行したいシェルスクリプトを用意する
シェルで実行したい内容を含めたものを、run.sh として作成します。
関連するリソースがある場合は、run.sh と同じ階層において参照できるようにしておきます。Automatorで直接シェルスクリプトを実行すると $0 が取れないが、既にファイルになっているスクリプトが実行されるので $0
も問題なく使えます。
Automatorアプリケーションを作る
Automatorで新規書類を作成するが、このときに「アプリケーション」を選びます。
次に「AppleScriptを実行」タスクを追加し、以下の内容を入力。
on run {input, parameters} set appPath to path to current application set appPOSIXPath to POSIX path of appPath set cmd to "sudo " & appPOSIXPath & "Contents/Commands/run.sh" do shell script cmd with administrator privileges end run
今回はツールインストール想定だったので管理者権限をとっています。こうしておくことで管理者権限のパスワード確認ダイアログが表示されますし、キャンセルすればそこで動作が止まります。
4行目から"sudo " &
を外して5行目からwith administrator privileges
を取れば一般権限にできます。
なお、Automator上から実行すると、Automator自体の実行パスが取れてしまうので、Automatorの実行ボタンを押しての動作確認はできません。ここまでできたら保存してAutomatorは閉じてOKです。
Automatorにコマンドを含める
保存したAutomatorアプリケーションを右クリックして「パッケージの内容を表示」します。
Contents/Commands の中に実行したい内容を含めた run.sh を入れます。
ダイアログを表示する
ユーザーに完了通知をするなどをでダイアログを表示する場合はosascriptコマンドでAppleScriptを書いて実現しますが、ユーザーセッションとは切り離されたところでコマンドが実行されてしまうので、実行しているユーザーのUIDをとってlaunchctl経由で実行する必要があります。こんな感じで関数にしておくと良いでしょう。
#!/bin/bash showDialog() { user=$(python -c 'from SystemConfiguration import SCDynamicStoreCopyConsoleUser; import sys; username = (SCDynamicStoreCopyConsoleUser(None, None, None) or [None])[0]; username = [username,""][username in [u"loginwindow", None, u""]]; sys.stdout.write(username + "\n");') uid=$(id -u "$user") launchctl asuser $uid osascript -e "display dialog \"$1\" buttons {\"OK\"}" } showDialog "hello"
コード署名をする
他のマシンで動かすにはコード署名をして、Notarizationしておく必要があります。
macOSアプリ開発相当の操作になるので、Apple Developer ProgramもしくはApple Developer Enterprise Programを契約して、Developer ID Application証明書を発行しておくことと、Xcodeのインストールが必要です。
まず、アプリのBundle Identifierを変えておくと良いと思います。「パッケージの内容を表示」して、Contents/Info.plistをXcodeで開き、CFBundleIdentifierを任意のものに変更します。
Bundle Identifierを変更したら、ターミナルでコードサインします。Automatorアプリの場合は、--deep
を付けるのがミソらしいです。
$ codesign -s "Developer ID Application: Iseteki Shinjyoushiki (XXXXXXXXXX)" \ --deep --force --timestamp -o runtime MyAutomatorApp.app
コードサインしたら、zipに圧縮します。なぜかzip -r
で圧縮するとNotarizationが失敗したので、Finderの右クリックから圧縮すると良いです。
最後に、Notary Service へ送信します。
$ xcrun altool --notarize-app \ --username <Apple ID> --password <Apple ID Password> \ --primary-bundle-id "net.iseteki.MyAutomatorApp" \ --file MyAutomatorApp.zip
ちなみに、Apple IDに複数のチームが登録されている場合は、--list-providers
で先に対象となるチームのProdiverShortNameを調べておいて、--asc-provider
引数で指定する必要があります。
$ xcrun altool --list-providers \ --username <Apple ID> --password <Apple ID Password> ProviderName ProviderShortname PublicID WWDRTeamID ------------------------- ----------------- ------------------------------------ ---------- Iseteki Shinjyoushiki XXXXXXXXXX xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx XXXXXXXXXX Shin ISE YYYYYYYYYY xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx YYYYYYYYYY $ xcrun altool --notarize-app \ --username <Apple ID> --password <Apple ID Password> \ --asc-provider XXXXXXXXXX \ --primary-bundle-id "net.iseteki.MyAutomatorApp" \ --file MyAutomatorApp.zip
しばらくすると、Notarizationの結果がApple IDのメールアドレスにメールで送られてきます。
※Notarizationについては、このブログでも過去に紹介したことがありますのであわせて読んでみてください。
iseebi.hatenablog.com iseebi.hatenablog.com
参考サイト
- Automator のシェルスクリプトで $0 を取得する - のき屋
- 【Mac】Apple Scriptのdisplay dialogの練習がてらじゃんけんアプリを作ってみる - Qiita
- 鳶嶋工房 / AppleScript / 入門 / ファイル指定を覚えよう
- User Interaction from bash Scripts – Scripting OS X
- Notarizing Automator applications | Der Flounder
- Notarization for Automator application - Apple Developer Forums
- Xcodeで実機実行する際にcode sign error resource fork, Finder information, or similar detritus not allowedで起動できない時の対処方法 – ダメ人間卒業研究所