• 作成:

mcp-nixosのHTTPエンドポイントをmicrovm.nixで建ててパブリックに公開しました

背景

mcp-nixosは、 nixpkgsのパッケージ情報やNixOSのオプション、Home Managerのオプションなどの、 Nixに関する情報をMCP(Model Context Protocol)経由で提供するサーバです。 AIアシスタントがNixOS関連のパッケージ名や設定を、不正確になりやすいWebFetch経由ではなく容易に検索できるようになります。

PC上のClaude DesktopやClaude Codeからnixでプロセスを立ち上げて使っていたのですが、 AndroidのClaude(claude.ai)からも使いたくなりました。 claude.aiのRemote MCP機能を使えば、 HTTPエンドポイントを持つMCPサーバに接続できます。

なぜパブリックにしたのか

最初はTailscaleなどの認証付きネットワーク経由で公開しようと考えていました。流石に知らない人からのリクエストを自分が実装したわけではないプログラムで処理するのは怖いので。

なので一回TailscaleのTailnetから接続できるように、 sparfenyuk/mcp-proxy: A bridge between Streamable HTTP and stdio MCP transports などを使って構築してみました。

しかしclaude.aiのRemote MCPは自分のマシンではなくAnthropicのサーバからリクエストが飛んでくるので、 Android端末がTailscaleに接続していても、 Tailscaleのデバイス認証は使えませんでした。

OAuthを使う方法もありますが、使うのが自分一人だとしてもOAuthの実装はかなり面倒くさいことが分かりました。そもそもmcp-nixosは読み取り専用のMCPサーバなので、認証をつける必然性はないはずです。面倒くささに見合わないのでやめました。

なのでパブリックに公開することにしました。

どうせパブリックにするなら、 NixOSコミュニティへの貢献にもなります。自分以外の人もclaude.aiなどからmcp-nixosを使えるようになるので。

リスクについては、仮想マシンで隔離すれば万が一脆弱性を突かれても被害を最小限に抑えられると判断しました。現状でもCloudflare TunnelやTailscaleで自宅サーバのサービスをインターネットに公開しているので、程度問題ですがリスクはどうせ常に抱えているものと割り切ります。

なぜmicrovm.nixか

microvm.nixは、 NixOS Flakeを使って軽量なNixOS仮想マシンを構築・実行するプロジェクトです。 QEMUcloud-hypervisorFirecracker(AWS Lambdaの基盤として使われています) など複数のハイパーバイザーに対応しています。

以前、 NixOS containersでForgejoとAtticdをコンテナ化した - ncaq に書いたように、その時はNixOS Containersでサービスを分離していました。 NixOS Containersはsystemd-nspawnベースのコンテナで、コンテナのご多分に漏れずカーネルをホストと共有します。

今回はあまり知らないコードが不特定のリクエストを処理するので、仮想マシンの方が安心です。例えばforgejoはログインしないとトップページにアクセスできるだけの設定にしているので、 Codebergとかもログイン周りは検証するでしょうし、変なことができる可能性は少ないでしょう。今回のmcp-nixosはいきなり本物のリクエストを送信できるので、どう動くかイマイチわからない怖さがあります。

コンテナだと共有カーネルの攻撃対象領域が広いですが、仮想マシンならカーネルが分離されるうえに、ハードウェアも仮想化されるため攻撃対象領域がハイパーバイザーとそのデバイスドライバーに限定されます。

microvm.nixを選んだ理由は、 NixOS Containersと同じように宣言的に書けるからです。実際使ってみたところ、コンテナとほぼ同じ感覚で設定を書けました。

mcp-nixosのHTTPモード起動方法の調査

mcp-nixosはFastMCPフレームワークを使用しています。 FastMCP 2.xは複数のトランスポートモードをサポートしているため、コード変更なしでHTTPモードで起動できます。

コマンドのイメージは以下のような感じです。

fastmcp run mcp_nixos.server:mcp --transport streamable-http --host 0.0.0.0 --port 8080

streamable-httpが現在推奨されているトランスポートです。 SSEもサポートされていますが後方互換性のためのもので、新規では使わない方が良いでしょう。

実装

feat: mcp-nixosのHTTPエンドポイントを建てる by ncaq · Pull Request #582 · ncaq/dotfiles で実装しました。

コンテナとmicroVMのアドレス管理を統一

既存のcontainer-mapping.nixmachine-mapping.nixにリネームして、コンテナとmicroVMの両方を統一的に管理するようにしました。

options.machineAddresses = lib.mkOption {
  type = lib.types.attrsOf addressType;
  default = {
    forgejo = {
      host = "192.168.100.10";
      guest = "192.168.100.11";
    };
    atticd = {
      host = "192.168.100.20";
      guest = "192.168.100.21";
    };
    mcp-nixos = {
      host = "192.168.100.30";
      guest = "192.168.100.31";
    };
  };
  description = "Machine (container/microVM) network addresses";
};

containerAddresses.*.containermachineAddresses.*.guestに変更しました。 guestという名前ならコンテナでもmicroVMでも意味が通じます。

NATの内部インターフェースにもvm-+(microVMのTAPインターフェース)を追加しています。

networking.nat = {
  enable = true;
  internalInterfaces = [
    "ve-+" # container veth interfaces
    "vm-+" # microVM TAP interfaces
  ];
};

vsock CIDの一元管理

これは後の実装なのですが、 refactor: microVMのvsock CID管理をmachine-mappingに一元化 by ncaq · Pull Request #600 · ncaq/dotfiles で、 vsock CIDの割り当てもmachine-mapping.nixで管理するようにしました。

options.microvmCid = lib.mkOption {
  type = lib.types.attrsOf (lib.types.ints.between 3 4294967294);
  default = {
    mcp-nixos = 3;
  };
  description = "vsock CID assignments for microVMs (must be >= 3, unique per VM)";
};

vsock CIDは仮想マシンを識別する32bit整数値です。 0はハイパーバイザー、 1はループバック、 2はホストに予約されているため、 3以上を使います。

cloud-hypervisorはvsock経由でsystemd-notifyが使えるので、ホストのsystemdがVM内のサービス起動完了を正確に検知できます。

重複チェックのアサーションも一応入れてあります。 microVMが増えてもCIDの衝突を防ぎやすくなります。

mcp-nixos.nixの設定

microVM設定

{ pkgs, config, ... }:
let
  addr = config.machineAddresses.mcp-nixos;
  # Pythonの依存関係とパッケージ自体をまとめて環境にします。
  # mcp-nixosコマンドを直接実行するわけではないので依存パッケージの別途指定が必要です。
  mcp-nixos-env = pkgs.python3.withPackages (
    _: pkgs.mcp-nixos.propagatedBuildInputs ++ [ pkgs.mcp-nixos ]
  );
  # HTTPで提供するためにmcp-nixosをコマンドラインで動かすのではなくPythonモジュールを呼び出します。
  serverPy = "${pkgs.mcp-nixos}/${pkgs.python3.sitePackages}/mcp_nixos/server.py";
in
{
  microvm.vms.mcp-nixos = {
    inherit pkgs;
    config = {
      microvm = {
        hypervisor = "cloud-hypervisor";
        vcpu = 1;
        mem = 768;
        vsock.cid = config.microvmCid.mcp-nixos;
        interfaces = [
          {
            type = "tap";
            id = "vm-mcp-nixos";
            mac = "02:00:00:00:00:30";
          }
        ];
        shares = [
          {
            tag = "ro-store";
            source = "/nix/store";
            mountPoint = "/nix/.ro-store";
            proto = "virtiofs";
          }
        ];
      };
      # ...
    };
  };
}

ポイント

依存関係の解決

ここで少し苦労しました。 mcp-nixosはstdioモードで動かす前提のパッケージなので、 fastmcp runでHTTPサーバとして動かすには依存関係を自分で解決する必要があります。

pkgs.python3.withPackagesでmcp-nixosのpropagatedBuildInputsとmcp-nixos自体をまとめたPython環境を作り、その環境のfastmcpコマンドを使うことで解決しました。

mcp-nixos-env = pkgs.python3.withPackages (
  _: pkgs.mcp-nixos.propagatedBuildInputs ++ [ pkgs.mcp-nixos ]
);

cloud-hypervisorの選択

ハイパーバイザーにはcloud-hypervisorを使いました。 Rustで書かれていて、 vsock対応でsystemd-notify連携もできます。

別にfirecrackerでも多分問題なかったのですが、まずmicrovm.nixで使うハイパーバイザーはフットプリントとかを考えて統一したいと思うので、機能の豊富なcloud-hypervisorを選びました。後から一部だけ変えたりあまりしたくないので。

firecrackerの一番のメリットはシンプルさゆえのパフォーマンスの良さらしいので、パフォーマンスに悩まされたら考えます。

リソース割り当て

vCPUは1、メモリは768MBです。 mcp-nixosはPythonのHTTPサーバなのでこの程度で十分です。

/nix/storeの共有

ホストの/nix/storeをvirtiofs経由で読み取り専用マウントしています。これにより仮想マシン側でNixのパッケージを利用できます。

HTTPサーバのsystemdサービス

systemd.services.mcp-nixos-http = {
  description = "mcp-nixos HTTP server";
  after = [ "network.target" ];
  wantedBy = [ "multi-user.target" ];
  serviceConfig = {
    ExecStart = "${mcp-nixos-env}/bin/fastmcp run ${serverPy}:mcp --transport streamable-http --host 0.0.0.0 --port 8080";
    DynamicUser = true;
    Restart = "always";
    RestartSec = 5;
    NoNewPrivileges = true;
    ProtectSystem = "strict";
    ProtectHome = true;
    PrivateTmp = true;
    PrivateDevices = true;
  };
};

仮想マシンの中でさらにsystemdのハードニングオプションを適用しています。 DynamicUserで動的にユーザーを生成し、 ProtectSystemやProtectHomeでファイルシステムへのアクセスを制限しています。

これでmcp-nixosに脆弱性があり、 systemdのハードニングを突破されて、 cloud-hypervisorにも脆弱性があるという状況でない限りは、被害はあまり出ないはずです。これなら多少は安心してパブリックにできます。

ExecStartではmcp-nixos-envで作ったPython環境のfastmcpを使っています。 mcp-nixosの依存関係を正しく解決するためです。

帯域制限

帯域制限はmicroVMの外側、ホストのTAPインターフェースに対して設定しています。

systemd.services.mcp-nixos-traffic-control = {
  description = "Traffic control for mcp-nixos microVM";
  requires = [ "microvm-tap-interfaces@mcp-nixos.service" ];
  after = [ "microvm-tap-interfaces@mcp-nixos.service" ];
  bindsTo = [ "microvm-tap-interfaces@mcp-nixos.service" ];
  wantedBy = [ "microvm-tap-interfaces@mcp-nixos.service" ];
  serviceConfig = {
    Type = "oneshot";
    RemainAfterExit = true;
    ExecStart = "${pkgs.iproute2}/bin/tc qdisc replace dev vm-mcp-nixos root tbf rate 100mbit burst 10mbit latency 400ms";
  };
};

万が一脆弱性を突かれて踏み台にされた場合に、外部への攻撃トラフィックで迷惑をかけないよう帯域を100mbitに制限しています。

100Mbpsは少し多いかもしれませんが、 nixpkgsのHTTPサーバならこれぐらいはキャッシュとかで耐えてくれるでしょう。多分向こうのWAFがDoSは弾くと思いますし、そもそも念の為の制限で悪用されるとはあまり思っていないので、これで十分です。

tc qdisc replaceを使うことで冪等性を確保しています。

TAPインターフェースのライフサイクルにbindsToで紐づけることで、 microVMの停止時にも適切にクリーンアップされます。

Cloudflare Tunnelの設定

"mcp-nixos.ncaq.net" = "http://${config.machineAddresses.mcp-nixos.guest}:8080";

Cloudflare Tunnel経由でhttps://mcp-nixos.ncaq.net/mcp/としてアクセスできるようにしました。

後から修正した部分

コンテナvethインターフェースの不要なfirewall信頼設定を削除

副産物として、 fix: コンテナvethインターフェースの不要なfirewall信頼設定を削除 by ncaq · Pull Request #601 · ncaq/dotfilesnetworking.firewall.trustedInterfacesからve-+を削除しました。

ホストからコンテナへの通信はホスト側が接続を開始するため、 conntrackによりESTABLISHEDとして自動許可されます。コンテナが使っているPostgreSQLもUnixソケットのbind mount経由で接続しているのでネットワークを使いません。 microVMのTAPインターフェース(vm-+)も信頼設定なしで動作していたので、コンテナ側も同様に不要でした。

実装PRのレビューで「microVMを隔離する設計意図と矛盾する」と指摘されたのがきっかけです。

ホスト側TAPインターフェースのIP設定をsystemd-networkdに移行

fix: mcp-nixosのsystemdサービス順序サイクルを解消 by ncaq · Pull Request #606 · ncaq/dotfiles で、ホスト側のTAPインターフェースのIPアドレス設定をnetworking.interfacesからsystemd-networkdに移行しました。

networking.interfacesが生成するnetwork-addresses-*サービスはnetwork.targetより前に順序付けられます。しかしmicroVMのTAPインターフェースはnetwork.targetより後に作成されるため、サービス順序のサイクルが発生していました。

サイクルが発生していても一応起動はしていたのですが、正しく解決されないのは気持ちが悪いですね。

microvm.nixの推奨に従いsystemd-networkdを使うことで、インターフェース出現時にイベント駆動でIPが設定されるようになり、サイクルが根本的に解消されます。

systemd.network = {
  enable = true;
  networks."20-vm-mcp-nixos" = {
    matchConfig.Name = "vm-mcp-nixos";
    addresses = [
      { Address = "${addr.host}/24"; }
    ];
  };
};

感想

microvm.nixを使ってみて、 NixOS Containersに比べても別に全然面倒くさくなく宣言的に書けると感じました。 microvm.vms.<name>.configの中にNixOSの設定を書くだけで、コンテナのcontainers.<name>.configと同じ感覚です。

設定する側が意識する違いとしては固定メモリ割り当てが必要なことぐらいです。

パフォーマンスがそこまで気にならない用途であれば、セキュリティを重視したいときに気軽に使えそうです。

今回のようにインターネットに直接公開するサービスは仮想マシンで隔離して、内部向けのサービスはコンテナで軽く動かすという使い分けが良いのかなと思います。

逆にDocker/Podmanの立場が自分の中では微妙になってきた気がします。軽い分離ならNixOS Containersで十分ですし、ちゃんと分離するなら仮想マシンを建てる方が安心です。まあDockerイメージはソフトウェア開発者が公式に配布していることが結構あるので、それを使ってサクッと動かすときはDockerもまだまだ便利だと思います。

自宅サーバの話であって、プロジェクト検証環境とかではDocker Composeもまだまだ使いますしね。

まとめ

mcp-nixosのHTTPエンドポイントをmicrovm.nixで建ててパブリックに公開しました。

  • AndroidのClaude(claude.ai)からNixOS情報にアクセスするのが目的
  • 読み取り専用MCPに認証は不要と判断してパブリック公開
  • microvm.nixでcloud-hypervisorベースの仮想マシンに隔離
  • 帯域制限やsystemdハードニングで踏み台対策
  • Cloudflare Tunnel経由でhttps://mcp-nixos.ncaq.net/mcp/として公開
  • 既存のコンテナ管理と統一するためmachine-mapping.nixに一元化

主なソースコードはncaq/dotfilesにあります。