QnapにCloudflare Zero Trustで外部から安全にアクセスする
Cloudflare Zero Trustを使用してQnapのNASに外部からアクセスできるようにします。 安全にアクセスするためにGoogleアカウントによる認証を挟むようにします。
このやり方には独自ドメインが必要になるため、所持していない場合はTailscaleを使う方法がオススメです。 アクセスするクライアント側にもTailscaleのインストールが必要になりますが、全体の設定手順はTailscaleのほうが簡単です。
環境
- Qnap TS-431P
構築の流れ
- Qnapにcloudflaredをインストールする
- Cloudflareにドメインを登録する
- Cloudflareに許可するGoogleアカウントを登録する
- Cloudflare Accessの設定をする
Qnapにcloudflaredをインストールする
Cloudflare Zero Trustを使うのに必要なcloudflaredをQnapにインストールします。
大きく分けて2つの方法があり、アプリリポジトリを追加する方法と手動でインストールする方法です。
アプリリポジトリを追加する方法
非公式のリポジトリであるQPKG Storeを追加してそこからインストールします。
追加方法は公式のドキュメントを参照してください。
追加後にApp CenterからCloudflared CLI
を検索してインストールします。
手動でインストールする方法
手動でインストールする場合は最初に使用しているQnapのCPUアーキテクチャを確認します。
Qnapにsshして以下のコマンドを実行します。
$ uname -a Linux QNAP 4.2.8 #2 SMP Sat Sep 3 06:31:33 CST 2022 armv7l unknown
出力結果の右から2つ目に注目します。
TS-431Pの場合はarmv7l
です。
この結果からTS-431Pはarmの32bitであることがわかります。
アーキテクチャがわかったら適切なバイナリを公式からダウンロードします。
例として以下のコマンドでダウンロードできます。
$ wget https://github.com/cloudflare/cloudflared/releases/download/2022.10.2/cloudflared-linux-arm $ chmod 755 cloudflared-linux-arm $ mv cloudflared-linux-arm cloudflared
注意点としてHomeディレクトリや/binなどは永続化されておらず再起動のタイミングで初期化されてしまうため、/share/homes/admin
などのNASの保存領域に配置する必要があります。
Cloudflareにドメインを登録する
新規に取得する場合はこちらのドキュメントを
移管する場合はこちらのドキュメントを参照してください。
Cloudflareに許可するGoogleアカウントを登録する
Googleアカウントでログインできるように設定します。
Settings > Authentication > Login methods
にあるAdd new
をクリックしGoogle
を選択します。
右側に表示されるドキュメントにそって設定します。
同等のドキュメントはこちらです。
次にAccess > Access Groups
のAdd a Group
をクリックします。
任意のGroup name
を入力したあとGroup configuration
でEmails
を選択し、ログインに使いたいGoogleアカウントのメールアドレスを入力します。
その後、上部にあるSave
ボタンをクリックして完了です。
Cloudflare Accessの設定をする
CLIの認証
cloudflaredを使用するためにログインします。
$ cloudflared tunnel login Please open the following URL and log in with your Cloudflare account: https://xxxxxxxxxx
出力されたURLをブラウザで表示し認証します。 アクセスに使うドメインを選択するようにしてください。
トンネルの作成
Qnapへのアクセスに使うトンネルを作成します。
$ cloudflared tunnel create [tunnel_name] Created tunnel [tunnel_name] with id [UUID]
コマンドを実行したディレクトリかHomeディレクトリに.cloudflared
が作成されているはずで、これも再起動しても消えない/share/homes/admin
などに移動させておきます。
.cloudflared
配下にある[UUID].json
というファイルはこの後の作業で使用するため作成されているか確認してください。
DNS登録
上で作成したトンネルにアクセスできるようにDNSに登録します。
$ cloudflared tunnel route dns [UUID] [domain]
トンネル作成時に出力されたUUIDを入力します。
configファイルの作成
永続化されている任意のディレクトリに config.yml
を作成します。
今回は /share/homes/admin/.cloudflared
に作成します。
$ vim /share/homes/admin/.cloudflared/config.yml
以下が内容の例です。
logDirectory: /var/log/cloudflared tunnel: [UUID] credentials-file: /share/homes/admin/.cloudflared/[UUID].json ingress: - hostname: [your_domain] service: http://localhost:8080
credentials-file
のパスやUUIDは各自の内容に書き換えてください。
また、Qnapのwebコンソールのポート番号を変えている場合はそこの変更も必要です。
ブラウザからアクセスするときの認証設定
ここからはCloudflareのwebコンソールでの作業になります。
Access > Applications
のAdd an application
をクリックします。
Self-hosted
を選択しApplication name
とApplication domain
に適切な値を入力します。
その他の入力項目は必要であれば適宜入力します。
上部のNext
をクリックしポリシーを設定します。
Policy name
とSession duration
に任意の値を入力します。
その他の入力項目は必要であれば適宜入力します。
上部のNext
をクリックし次の入力画面に移動します。
ここも必要であれば入力し上部のAdd an application
をクリックして完了です。
手動で起動する
ここからはQnapのCLIでの作業になります。
まずは手動でトンネルを起動して動作確認してみます。
$ cloudflared tunnel --config /share/homes/admin/.cloudflared/config.yml run
赤文字のエラーが出力されなければ成功です。 ブラウザで指定したドメインからアクセスしてみましょう。
再起動後もトンネルを起動させる
OSの更新などで再起動された後も自動的に起動するようにします。
Qnapには起動時にスクリプトを実行してくれる機能があるため、ここにトンネルの起動コマンドを記述します。 この機能の使い方はハードウェアごとに異なるため詳細は公式ドキュメントを参照してください。
TS-431P
の場合は以下の手順で記述できます。
$ ubiattach -m 6 -d 2 $ /bin/mount -t ubifs ubi2:config /tmp/config $ vi /tmp/config/autorun.sh $ chmod +x /tmp/config/autorun.sh $ umount /tmp/config $ ubidetach -m 6
autorun.sh
を開いたら以下のコマンドを追記します。
/opt/bin/nohup sudo cloudflared tunnel --config /share/homes/admin/.cloudflared/config.yml run &
保存後、再起動したあとも指定したドメインからアクセスできれば設定完了です。
IntelliJ IDEAで大きなファイルがインデックスされないときの解決方法
GoLandやIntelliJ IDEAなどJetBrainsのIDEで大きなファイルがインデックスされないときの解決方法を紹介します。
TL;DR
Help
>Edit Custome Properties...
をクリック- 開かれたファイルに
idea.max.intellisense.filesize=10000000
と追記する
発生した問題
GoLandでパッケージ google.golang.org/genproto
をインポートしてコードを書こうとしたときにエラーを表す赤線が発生してしまいました。
エラーになっているためコードの補完も効きません。
調査内容
go modulesでのインストールに問題が発生していないか確認する
これらのことからパッケージのインストールに問題は発生していないことがわかります。
別のエディタでエラーになるか
ちょっとしたコードを書くときはvimを使用しているため、vimからgoplsを使って該当パッケージのコードを書いてみました。
結果はエラーにならず補完も動きました。
これらのことからGoLandのインデックスに問題がありそうなことが想像できます。
GoLand + パッケージ名で検索してみる
検索してみたところ、関係のありそうな投稿がされたJetBrainsのフォーラムがでてきました。
https://youtrack.jetbrains.com/issue/GO-10493
中を見てみると、ファイルのサイズ上限を上げてあげると良いようです。
GoLandの Help
> Edit Custome Properties...
をクリックし表示されたファイルに以下を追記すれば上限を上げられます。
idea.max.intellisense.filesize=10000000
追記後に再起動などでインデックスの再構築が行われればパッケージのエラーは解消されました。
補足
エラーになったパッケージはコードを自動生成していたため、1つのファイルにコードがまとまっていてサイズは数MBほどありました。
大きなファイルを読み込むとIDEのパフォーマンスに影響がでるそうです。
補足2
このGoogleのパッケージ以外にAWSのパッケージなどでも同様の問題が発生していたこともあり、デフォルトの制限が10MBに引き上げられました。
最新バージョンのIDEを使っている場合はこの問題は発生しないと思われます。
GitHub ActionsからGCPにOpenID Connectでアクセスする
GitHub ActionsがOIDC (OpenID Connect)に対応したと公式から発表があったため、GCPへのアクセスを行ってみました。
GCPのWebコンソールやgcloudコマンドを使ったやり方は公式ドキュメントで紹介されているため、ここではTerraformを使ったやり方を紹介します。
上記の公式ドキュメントで紹介されているコマンドはデモ用のためセキュリティ的にはあまり良い状態ではありません。
今回は特定のリポジトリのみで使用できるように設定していきます。
Terraformで設定する
まずはGitHub Actionsで使用するサービスアカウントを作成します。
resource "google_service_account" "demo" { account_id = "demo" display_name = "demo" description = "demo" }
サービスアカウントへの権限設定は今までと変わらないため省略します。
次にWorkload Identity Poolを作成します。
GCPによると環境によって分けることを推奨するようです。
Workload Identity プールは相互に分離されていますが、1 つのプールは任意の数のサービス アカウントになり代わることができます。一般に、開発、ステージング、本番環境など、環境ごとに新しいプールを作成することをおすすめします。
resource "google_iam_workload_identity_pool" "github_actions_demo" { provider = google-beta workload_identity_pool_id = "github-actions-demo" display_name = "GitHub Actions demo" disabled = false }
次は先程作成したPoolにWorkload Identity Providerを作成します。
ここで行う属性のマッピングがセキュリティを高めるために重要になってきます。
リポジトリで権限の制御を行うために "attribute.repository" = "assertion.repository"
として属性にリポジトリ名を追加します。
リポジトリだけではなくブランチに対しても制御することができます。
例として、mainブランチのみにデプロイ用の権限を許可する、などがあるかと思います。
resource "google_iam_workload_identity_pool_provider" "demo" { provider = google-beta workload_identity_pool_id = google_iam_workload_identity_pool.github_actions_demo.workload_identity_pool_id workload_identity_pool_provider_id = "demo-provider" display_name = "demo" attribute_mapping = { "google.subject" = "assertion.sub" "attribute.actor" = "assertion.actor" "attribute.aud" = "assertion.aud" "attribute.repository" = "assertion.repository" } oidc { issuer_uri = "https://token.actions.githubusercontent.com" } }
属性のマッピングについての詳細は以下の公式ドキュメントが参考になります。
最後にサービスアカウントと紐付けます。
resource "google_service_account_iam_binding" "gha_demo" { service_account_id = google_service_account.demo.name role = "roles/iam.workloadIdentityUser" members = [ "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github_actions_demo.name}/attribute.repository/[your_github_name]/[your_repository_name]", ] }
最後の [your_github_name]/[your_repository_name]
はサービスアカウントの使用を許可するリポジトリになります。
GitHub Actions
GitHub Actionsでは以下のようにProviderとサービスアカウントの指定をすればアクセスできます。
jobs: demo: runs-on: ubuntu-latest permissions: contents: 'read' id-token: 'write' steps: - name: Authenticate to Google Cloud id: auth uses: google-github-actions/auth@v0.3.1 with: create_credentials_file: 'true' workload_identity_provider: 'projects/xxxxxxxxxxx/locations/global/workloadIdentityPools/github-actions-demo/providers/demo-provider' service_account: 'demo@xxxxxxxxxxxx.iam.gserviceaccount.com' - name: gcloud run: |- gcloud auth login --brief --cred-file=${{ steps.auth.outputs.credentials_file_path }}
client-goでDynamic Clientを使用して複数のマニフェストを適用する
client-goで単体のリソースを作成する場合は以下のように明示的に対象のclientを作成することで行うことができます。
func main() {
...
deployment := &appsv1.Deployment{}
deploymentsClient := clientset.AppsV1().Deployments(apiv1.NamespaceDefault)
result, err := deploymentsClient.Create(context.TODO(), deployment, metav1.CreateOptions{})
}
しかし、このやり方だと以下のような複数の種類リソースがまとまったyamlや、どのような種類を扱うか不明なときには利用するのが難しくなります。
apiVersion: v1 kind: Namespace metadata: name: example --- apiVersion: v1 kind: ServiceAccount metadata: name: example
そこでDynamic Clientを使うと、このような状況でもclient-goでリソースの作成をすることができます。
Dynamic Client
Dynamic Clientを使用して上記のyamlを適用していきます。
Dynamic Clientの初期化
通常のClientを作成するときと同じようにDynamic Clientに必要な変数を初期化します。
import ( "k8s.io/apimachinery/pkg/api/meta" "k8s.io/client-go/discovery" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/restmapper" "k8s.io/client-go/tools/clientcmd" ) var ( client kubernetes.Interface discoveryClient discovery.DiscoveryInterface mapper meta.RESTMapper dynamicClient dynamic.Interface config *rest.Config ) type Dynamic struct { Client dynamic.Interface Discovery discovery.DiscoveryInterface Mapper meta.RESTMapper } func InitK8s() error { conf, err := clientcmd.BuildConfigFromFlags("", clientcmd.RecommendedHomeFile) if err != nil { return err } config = conf clientset, err := kubernetes.NewForConfig(conf) if err != nil { return err } client = clientset discoveryClient = clientset.Discovery() groupResources, err := restmapper.GetAPIGroupResources(discoveryClient) if err != nil { return err } mapper = restmapper.NewDiscoveryRESTMapper(groupResources) dyn, err := dynamic.NewForConfig(config) if err != nil { return err } dynamicClient = dyn return nil }
ポイントとしては RESTMapper
を作成するときにDynamic Clientを構築するために必要な情報をAPI Serverから取得するため、毎回実行してしまうとサーバーに負荷をかけてしまうので予め作成しておく必要があります。
全体の流れ
k8s.io/apimachinery
に付属する関数を使用することでリソース単位のオブジェクトに分割できます。
分割したリソースごとにClientを構築してマニフェストを適用していきます。
import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/yaml" ) func main() { ... dynamic := Dynamic{} manifest := "..." decoder := yaml.NewYAMLOrJSONDecoder(strings.NewReader(manifest), 100) for { var rawObj runtime.RawExtension if err := decoder.Decode(&rawObj); err != nil { break } obj := &unstructured.Unstructured{} client, err := dynamic.NewClient(rawObj.Raw, obj) res, err := dynamic.Apply(client, obj) ... } }
Dynamic Clientの構築
まずは分割したリソースからclientが適切なAPIにアクセスするための情報をパースして、Dynamic Clientを構築します。
func (d Dynamic) NewClient(data []byte, obj *unstructured.Unstructured) (dynamic.ResourceInterface, error) { dec := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) _, gvk, err := dec.Decode(data, nil, obj) if err != nil { return nil, err } mapping, err := d.Mapper.RESTMapping(gvk.GroupKind(), gvk.Version) if err != nil { return nil, err } if mapping.Scope.Name() == meta.RESTScopeNameNamespace { if obj.GetNamespace() == "" { obj.SetNamespace(metav1.NamespaceDefault) } return d.Client.Resource(mapping.Resource).Namespace(obj.GetNamespace()), nil } else { return d.Client.Resource(mapping.Resource), nil } }
上記コード gvk
の gvk.String()
を出力すると apps/v1, Kind=DaemonSet
のような情報が出力されます。
これがDynamic Clientを構築するために必要な情報です。
この情報をもとに先程作成した RESTMapper
を経由してAPI情報を取得してDynamic Clientを構築します。
Create
Dynamic Clientの構築が完了したので、リソースを作成します。
kubectl create
相当のものはこのようになります。
func (d Dynamic) Create(ctx context.Context, client dynamic.ResourceInterface, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { return client.Create(ctx, obj, metaV1.CreateOptions{ FieldManager: "example", }) }
また、 kubectl apply
相当のものはこのようになります。
func (d Dynamic) Apply(ctx context.Context, client dynamic.ResourceInterface, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { data, err := json.Marshal(obj) if err != nil { return nil, err } return client.Patch(ctx, obj.GetName(), types.ApplyPatchType, data, metaV1.PatchOptions{ FieldManager: "example", }) }
このようにしてDynamic Clientを構築することで、複数のリソースがまとまったマニフェストをclient-goを使用して適用することができます。
KubernetesのJobを管理するカスタムコントローラーを作成した
KubernetesのJobは完了しても登録したJobは削除されず kubectl get job
などを実行したときの視認性がよくありません。
これを解消するための設定として .spec.ttlSecondsAfterFinished
がありますが、長いことalpha版でGKEでは使用することができません。
単純に消すだけであればclient-goを使って書いたスクリプトをCronJobで定期実行すればよさそうですが、カスタムコントローラーを作ってみたかったため akaimo/job-observer
というコントローラーを作成してみました。
akaimo/job-observer
カスタムコントローラーを作るにあたっていくつかの方法がありますが、Kubernetes本体のことも知りたかったため、本家のコントローラーと同様のアプローチである k8s.io/code-generator
を使って作成しました。
インストール方法
デフォルト設定のコントローラーをインストールする場合は以下の一行を実行するだけです。
$ kubectl apply -f https://raw.githubusercontent.com/akaimo/job-observer/master/bundle.yaml
これはhelmのtemplateコマンドで出力したものなのでカスタマイズができます。
現状はあまり多くのことはできませんが...
カスタムリソース
Cleaner
リソースでPodの停止から任意の時間が経過したJobを削除することができます。
apiVersion: job-observer.akaimo.com/v1alpha1 kind: Cleaner metadata: name: my-cleaner spec: ttlAfterFinished: 1m cleaningJobStatus: Complete selector: matchLabels: app: my version: mikan
削除対象を絞り込むことができ、ServiceでPodを指定するときと同じ方法でLabelを使って選択できます。
また、Jobの終了ステータスで指定することもできます。
Cleaner
リソースを複数個登録することで、対象の条件ごとに削除までの期間を指定できます。
例えば、成功したJobは1h、失敗したJobは24hといったような指定ができます。
今後について
今の所、わざわざコントローラーを入れるほどの内容ではないというのが正直なところです。
コントローラーを作成することにもなれてきたので、Jobの終了時にSlackなどに通知する機能を追加してみようかと考えています。
Ubuntuで開発環境を整える
MacのDocker周りの遅さに嫌気が差したのでUbuntuに移行してみることにしました。
自分用のメモも兼ねて構築の流れを残しておきます。
WSL2にしなかった理由
最初はWSL2で構築する予定でしたが、検証していると問題が発生したため辞めました。
中でも致命的だったのがGoのModulesがWindows側のIDEから実行できないことです。
小さなプロジェクトや大部分を把握している場合なんかはvimで開発していますが、Kubernetesなどの大きなもののコードを読むときはIDEがあると便利です。
コードを読むだけならWindows側に置いておけばいいかなとも思いましたが、Windowsのことをあまり理解したいと思えなかったので辞めました。
気が向いたら再チャレンジするかもしれません。
環境
自作PCに直接Ubuntuをインストールするとドライバやらが面倒なので仮想化しています。
また、仮想化していると簡単にバックアップとリストアができるので気軽にアップデートなどを行えて便利です。
構築の流れ
OS周り
- Ubuntuの英語版をインストール
- もろもろのパッチをインストールしLive Patchを有効化する
- マウスのサイドボタンを有効化
- https://akiki0106.hatenablog.com/entry/2017/02/22/122339
- vmxを編集するエディタによっては文字化けする可能性があるので注意
- 一行目に使用する文字コードが書かれているのでそれに合わせる
- 日本語環境の整備
- 日本語入力
- Font変更
- 中国語っぽい感じのフォントになっているので変更する
- 設定のManaged Installed LanguagesからJapaneseをインストール
- Firefoxのフォントは設定から言語を日本語に変更する変わる
- スケーリングの固定化
開発環境
- Terminal環境
- Terminatorを使用する
- MacのiTermのような使い方をしたい
- https://qiita.com/SUZUKI_Masaya/items/ddc9bcfaf6b09708fe32
- CLI環境
- zsh, dotfile, anyenv, vimをAnsibleで構築する
- https://github.com/akaimo/dotfiles
- Docker
- docker-compose
- minikubeのインストール
- JetBrainsのToolbox Appをインストール
構築完了
VMやOS周りの設定さえ終えればあとの大部分はAnsibleがやってくれます。
以前と比べるとUbuntuでも動くソフトウェアが増えていて(Toolbox Appや1Passwordなど)だいぶ扱いやすくなった印象です。
vimの補完プラグインasyncomplete-around.vimを作りました
asyncomplete-around.vimとは
asyncomplete-around.vim
は prabirshrestha/asyncomplete.vimのSourceプラグインです。
現在のカーソル位置を中心として上下20行(デフォルト値)に存在する単語を補完対象として表示します。
asyncomplete.vim
は補完ポップアップを表示するプラグインで、これに適した補完対象(Source)を渡すプラグインと組み合わせることでポップアップに単語が表示され補完できるようになります。
asyncomplete.vim
のソースプラグインとして代表的なものとして、LSPプラグインprabirshrestha/vim-lspのprabirshrestha/asyncomplete-lsp.vimがあります。
作った経緯
もともと補完プラグインとしてShougo/deoplete.nvimを使っていました。
使用感に不満はありませんでしたが、Python環境を用意するのが面倒だと感じていました。
LSPとしてprabirshrestha/vim-lsp
を使っていたため同じ作者のprabirshrestha/asyncomplete.vim
に乗り換えたところ、Buffer関連の補完が少し弱く、LSPで補完できないちょっとした単語を繰り返し入力することを面倒に感じていました。
少し調べると独自の補完ソースを追加できるとの記載があったため、asyncomplete-around.vim
を作成することにしました。
オススメ設定
ここで私の設定を紹介します。
コードの詳細はGitHubにて公開しているため、適宜そちらを参照してください。
補完範囲の設定
私は画面に表示されている単語が補完できれば良いかなと思っているため50行にしています。
let g:asyncomplete_around_range = 50
Priorityの設定
asyncomplete.vim
はpreprocessor
という仕組みがあり、補完の表示内容を編集することができます。
今回はそれを利用して補完ソースの表示順を使いやすいように制御します。
各ソースにPriorityを設定し、その値をもとにソートする関数をg:asyncomplete_preprocessor
に設定します。
function! s:sort_by_priority_preprocessor(options, matches) abort ... endfunction let g:asyncomplete_preprocessor = [function('s:sort_by_priority_preprocessor')] call asyncomplete#register_source(asyncomplete#sources#around#get_source_options({ \ 'name': 'around', \ 'allowlist': ['*'], \ 'priority': 10, \ 'completor': function('asyncomplete#sources#around#completor'), \ }))
私はasyncomplete-lsp.vim
, asyncomplete-file.vim
, asyncomplete-around.vim
という順番で表示させています。
asyncomplete-around.vim
は指定範囲内のマッチした単語が全て候補として表示されるため、LSPで候補となっている関数や変数よりも上に表示されてしまうと、とても使いづらくなってしまうためこの順番にしています。
asyncomplete-around.vimの今後
しばらく使用してみて私の要望を満たすものだと確信できたので、上位互換のプラグインが登場しない限り今後も使用、メンテナンスをしていきます。
asyncomplete-around.vim
は非同期化されていないため場合によってはカクつくことがあるかもしれません。
そのため、まずは非同期化を進めていきたいと思います。