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を追加してそこからインストールします。

追加方法は公式のドキュメントを参照してください。

www.qnapclub.eu

追加後にApp CenterからCloudflared CLIを検索してインストールします。

www.qnapclub.eu

手動でインストールする方法

手動でインストールする場合は最初に使用している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であることがわかります。

アーキテクチャがわかったら適切なバイナリを公式からダウンロードします。

github.com

例として以下のコマンドでダウンロードできます。

$ 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にドメインを登録する

新規に取得する場合はこちらのドキュメントを

developers.cloudflare.com

移管する場合はこちらのドキュメントを参照してください。

developers.cloudflare.com

Cloudflareに許可するGoogleアカウントを登録する

Googleアカウントでログインできるように設定します。

Settings > Authentication > Login methodsにあるAdd newをクリックしGoogleを選択します。 右側に表示されるドキュメントにそって設定します。

同等のドキュメントはこちらです。

developers.cloudflare.com

次にAccess > Access GroupsAdd a Groupをクリックします。 任意のGroup nameを入力したあとGroup configurationEmailsを選択し、ログインに使いたい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 > ApplicationsAdd an applicationをクリックします。 Self-hostedを選択しApplication nameApplication domainに適切な値を入力します。 その他の入力項目は必要であれば適宜入力します。

上部のNextをクリックしポリシーを設定します。

Policy nameSession durationに任意の値を入力します。 その他の入力項目は必要であれば適宜入力します。

上部のNextをクリックし次の入力画面に移動します。 ここも必要であれば入力し上部のAdd an applicationをクリックして完了です。

手動で起動する

ここからはQnapのCLIでの作業になります。

まずは手動でトンネルを起動して動作確認してみます。

$ cloudflared tunnel --config /share/homes/admin/.cloudflared/config.yml run

赤文字のエラーが出力されなければ成功です。 ブラウザで指定したドメインからアクセスしてみましょう。

再起動後もトンネルを起動させる

OSの更新などで再起動された後も自動的に起動するようにします。

Qnapには起動時にスクリプトを実行してくれる機能があるため、ここにトンネルの起動コマンドを記述します。 この機能の使い方はハードウェアごとに異なるため詳細は公式ドキュメントを参照してください。

wiki.qnap.com

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

  1. Help > Edit Custome Properties... をクリック
  2. 開かれたファイルに idea.max.intellisense.filesize=10000000 と追記する

発生した問題

GoLandでパッケージ google.golang.org/genproto をインポートしてコードを書こうとしたときにエラーを表す赤線が発生してしまいました。
エラーになっているためコードの補完も効きません。

調査内容

go modulesでのインストールに問題が発生していないか確認する

  • go.mod にパッケージの記載がある
  • go/pkg/mod/google.golang.org/genprotoソースコードが存在する
  • CLIからビルドできる

これらのことからパッケージのインストールに問題は発生していないことがわかります。

別のエディタでエラーになるか

ちょっとしたコードを書くときは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へのアクセスを行ってみました。

github.blog

GCPのWebコンソールやgcloudコマンドを使ったやり方は公式ドキュメントで紹介されているため、ここではTerraformを使ったやり方を紹介します。

docs.github.com

github.com

上記の公式ドキュメントで紹介されているコマンドはデモ用のためセキュリティ的にはあまり良い状態ではありません。
今回は特定のリポジトリのみで使用できるように設定していきます。

Terraformで設定する

まずはGitHub Actionsで使用するサービスアカウントを作成します。

resource "google_service_account" "demo" {
  account_id   = "demo"
  display_name = "demo"
  description  = "demo"
}

サービスアカウントへの権限設定は今までと変わらないため省略します。

次にWorkload Identity Poolを作成します。
GCPによると環境によって分けることを推奨するようです。

Workload Identity プールは相互に分離されていますが、1 つのプールは任意の数のサービス アカウントになり代わることができます。一般に、開発、ステージング、本番環境など、環境ごとに新しいプールを作成することをおすすめします。

cloud.google.com

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"
  }
}

属性のマッピングについての詳細は以下の公式ドキュメントが参考になります。

cloud.google.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
    }
}

上記コード gvkgvk.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から実行できないことです。

github.com

小さなプロジェクトや大部分を把握している場合なんかはvimで開発していますが、Kubernetesなどの大きなもののコードを読むときはIDEがあると便利です。
コードを読むだけならWindows側に置いておけばいいかなとも思いましたが、Windowsのことをあまり理解したいと思えなかったので辞めました。

気が向いたら再チャレンジするかもしれません。

環境

自作PCに直接Ubuntuをインストールするとドライバやらが面倒なので仮想化しています。

また、仮想化していると簡単にバックアップとリストアができるので気軽にアップデートなどを行えて便利です。

構築の流れ

OS周り

開発環境

構築完了

VMやOS周りの設定さえ終えればあとの大部分はAnsibleがやってくれます。

以前と比べるとUbuntuでも動くソフトウェアが増えていて(Toolbox Appや1Passwordなど)だいぶ扱いやすくなった印象です。

vimの補完プラグインasyncomplete-around.vimを作りました

asyncomplete-around.vimとは

asyncomplete-around.vimprabirshrestha/asyncomplete.vimのSourceプラグインです。

github.com

現在のカーソル位置を中心として上下20行(デフォルト値)に存在する単語を補完対象として表示します。

asyncomplete.vimは補完ポップアップを表示するプラグインで、これに適した補完対象(Source)を渡すプラグインと組み合わせることでポップアップに単語が表示され補完できるようになります。

asyncomplete.vimのソースプラグインとして代表的なものとして、LSPプラグインprabirshrestha/vim-lspprabirshrestha/asyncomplete-lsp.vimがあります。

作った経緯

もともと補完プラグインとしてShougo/deoplete.nvimを使っていました。
使用感に不満はありませんでしたが、Python環境を用意するのが面倒だと感じていました。

LSPとしてprabirshrestha/vim-lspを使っていたため同じ作者のprabirshrestha/asyncomplete.vimに乗り換えたところ、Buffer関連の補完が少し弱く、LSPで補完できないちょっとした単語を繰り返し入力することを面倒に感じていました。
少し調べると独自の補完ソースを追加できるとの記載があったため、asyncomplete-around.vimを作成することにしました。

オススメ設定

ここで私の設定を紹介します。
コードの詳細はGitHubにて公開しているため、適宜そちらを参照してください。

github.com

補完範囲の設定

私は画面に表示されている単語が補完できれば良いかなと思っているため50行にしています。

let g:asyncomplete_around_range = 50

Priorityの設定

asyncomplete.vimpreprocessorという仕組みがあり、補完の表示内容を編集することができます。
今回はそれを利用して補完ソースの表示順を使いやすいように制御します。

各ソースに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は非同期化されていないため場合によってはカクつくことがあるかもしれません。
そのため、まずは非同期化を進めていきたいと思います。