14 KiB
サーバー外向き通信の境界制御
更新日: 2026-09-08
適用状態
このリポジトリは、Linux systemd と既存 Squid を使う配布用 template を提供する。
maestro-egress.service.exampleは、MAESTRO service cgroupへ適用する IP 制限の staging 用設計例である。対象OSでの強制を検証するまで本番へ配備しない。squid.confは許可した hostname と port の組だけを転送する。check-egress-boundary.mjsは外部へ接続せず、template と proxy policy の回帰検査を行う。
ネットワーク制御の設計例の配布までで、実 OS へ隔離を適用した試験は未実施である。 調査環境には Docker CLI、Squid、nftables、tcpdump が無いため、Docker Compose 構成や実 host の cgroup-BPF 動作を完成扱いしない。template を install・enable する前に、対象 host の staging 環境で後述の確認が必要である。
制御モデル
graph LR
Client[利用者] --> Ingress[専用 ingress proxy<br/>source 127.0.0.3]
Ingress --> App[MAESTRO<br/>127.0.0.4]
App -->|HTTP_PROXY / HTTPS_PROXY| Proxy[Squid<br/>127.0.0.2:3128]
Proxy -->|exact hostname + port のみ| Approved[承認済み接続先]
App -. cgroup で拒否する設計・要実測 .-> Direct[直接 HTTP / WS / DNS / raw TCP]
設計例は systemd の IPAddressDeny=any と次の 2 address の IPAddressAllow を組み合わせる。kernel、systemd、container managerによってcgroup-BPF対応が異なるため、unit記述だけでは強制を証明しない。
| address | 用途 | 条件 |
|---|---|---|
127.0.0.2/32 |
外向き Squid | この address には Squid 以外を bind しない。systemd の許可は port を限定できない。 |
127.0.0.3/32 |
ingress proxy の接続元 | listening service を置かず、ingress proxy が MAESTRO 向け接続の source address としてだけ使う。 |
127.0.0.3 はプロセスの identity や認証を保証せず、同一 host 上の別プロセスも source address として bind できる。この設計例は host 上の local user と process を信頼境界内とし、信頼できない local workload がある場合は network namespace や host firewall/cgroup identity による追加分離が必要である。
MAESTRO は 127.0.0.4 で listen する。systemd の IP filter は outbound の destination と inbound の source の両方へ同じ allow list を使う。このため、通常の 127.0.0.1 client や LAN subnet を IPAddressAllow へ足すと、その address/subnet は同時に外向き通信先としても許可される。localhost、127.0.0.0/8、::1/128、利用者 LAN 全体を便宜的に許可しない。ingress proxy に専用 source address を設定できない環境では、この template をそのまま有効化できない。
template に IPv6 の許可値はない。これは対象 host で IP policy が有効な場合にだけIPv6拒否になる。IPv6を使う場合も専用proxyとingress sourceの個別 /128 だけを追加し、別canaryで実測する。
Squid policy
初期設定の allowed.egress.invalid:443 は公開 DNS で解決しない canary であり、意図せず外部へ出ない。運用時は次の 3 行を接続先ごとに作り、hostname と port を置き換える。
acl provider_a_host dstdomain -n api.provider.example
acl provider_a_port port 443
http_access allow provider_a_host provider_a_port
exact hostname として扱うため、先頭に . を付けない。.example.com は subdomain も許可するため、この policy の既定には使わない。dstdomain -n はIP literalに対するreverse DNS検索を行わない。IP literalはhostname ACLに一致せず、末尾の http_access deny all で拒否される。許可 hostname が DNS rebinding で別 IP を返す可能性は proxy 側の名前解決・接続 policy として別途評価する。private、link-local、metadata CIDR へ解決した結果を禁止できる Squid ACL も staging で追加確認する。
access_log none と cache_store_log none が既定で、URL path、query、Authorization header、body、DNS query name を保存しない。DNS query name 自体に秘密が埋め込まれる場合があるため、packet capture や resolver の query log は既定の観測手段にしない。
設定画面で LLM、MCP、OAuth、webhook などの endpoint を追加しても、Squid policy は自動更新しない。アプリ自身が編集できる設定から境界の許可を自動生成すると、アプリ権限を得た主体が境界も広げられるためである。手順は「アプリ設定を保存する → default deny を確認する → 別権限の運用者が hostname+port を追加する → Squid を検証・reloadする」とする。
導入手順
maestro専用 user と、MAESTRO、ingress proxy、Squid が専用に使う address を staging host で用意する。- Squid template の
.invalidACL を、承認済み exact hostname+port に置き換える。 squid -k parse -f <path>で構文を確認し、Squid を127.0.0.2:3128に bind する。- systemd unit の
/opt/maestro、user/group、EnvironmentFile を実環境へ合わせる。 - ingress proxy の MAESTRO 向け source address を
127.0.0.3に固定する。MAESTRO は127.0.0.4で listen する。 systemd-analyze verify <unit>に加え、service cgroup内から独立したlistening canaryへ接続し、IPv4/IPv6/direct DNS/raw TCP/子processが拒否されることを確認してからenableする。接続拒否やtimeoutだけでBPFの有効性を断定せず、systemd journalとkernel側のpolicy attach状態も照合する。少数のcanary成功だけを全経路の証明にしない。
unit は server.sh を呼ばず、migration 後に dist/main.js を直接起動する。server.sh は起動ごとに prepare.sh と build を実行し、資産不足時には download が発生し得るためである。dependency準備とbuildは管理者が runtime service の外で明示的に行う。AAO_MODE=worker 用 template であり、gateway mode は別 unit と policy を作る。
.env 相当は /etc/maestro/maestro.env から読むが、内容をaudit outputに出さない。EnvironmentFileはEnvironment=を上書きできるため、proxy変数・HOST・modeはExecStartの/usr/bin/env引数で最終指定し、小文字proxy変数、ALL_PROXY、NODE_OPTIONSを先に除去する。NO_PROXY に外部 hostname、LAN、localhost を追加すると proxy を迂回するため空のままにする。
秘密を保存しない観測
unit の IPAccounting=yes では、service と子プロセスを合わせた ingress/egress packet・byte の集計を得られる。
systemctl show maestro-egress.service \
-p IPIngressBytes -p IPIngressPackets \
-p IPEgressBytes -p IPEgressPackets
この集計は URL、header、body、DNS name、API key を記録しない。一方、宛先や protocol は判別できず、拒否された試行の完全な台帳にもならない。
接続単位の短期観測が必要な場合、Squid template にある maestro_meta logformat を staging だけで有効にできる。記録項目は時刻、HTTP method、接続先 IP/port、status、bytes、時間で、URL/path/query/header/body/DNS name は含めない。file mode、retention、閲覧権限を先に決める。hostname が必要な場合も平文保存せず、運用側 collector で session ごとの鍵付き HMAC と policy rule ID に置き換える。
proxy経由のHTTP(S)はSquidが名前解決する。IP policyが有効ならAF_INET/AF_INET6でresolverへ直接送る通信もallow list外になるが、AF_UNIXはアプリ動作のため許可しており、nss-resolve、system bus、host socket上の代理serviceへ委譲する経路はこのtemplateだけでは遮断を保証できない。resolverのquery logは有効にせず、必要ならquery数とallow/deny数だけを集計する。
現在の通信との互換性
Node.js 要件は package.json の >=22 で、調査環境の Node 22.22.3 はexperimentalな --use-env-proxy を持つ。unit例はmain Node processだけにこのflagとHTTP_PROXY / HTTPS_PROXYを設定する。NODE_OPTIONSは子processへ強制継承しない。これは global fetch を proxy に通す補助策であり、境界そのものではない。
| 経路 | 境界有効時の見込み | 根拠・対応 |
|---|---|---|
LLM request と backend probe の global fetch |
Squid 経由で許可可能 | LLM は openai-compat.ts、probe は backend-probes.ts で global fetch を使う。 |
node:http / node:https を直接使う依存 |
OS 境界で拒否され得る | env proxyだけでは全 transport を覆わない。transport固有agentを確認してから許可する。 |
| Discord REST / resident WebSocket | 現状は拒否され得る | active binding は起動時にREST照会後、resident WSへ接続する。discord-gateway.ts discord-gateway.ts |
| Web Push | 現状は拒否され得る | web-push が独自 HTTPS transport を使うため、proxy対応を個別検証する。push-service.ts |
| MCP | transportごとに確認が必要 | 接続はSDK transportへ委譲される。client-factory.ts |
| SSRF strict fetch | 現状は拒否され得る | DNS pinning用の明示的 Undici Agentを渡すため、global dispatcherを使わない。ssrf-strict.ts |
| SSH | 拒否 | raw TCPであり、HTTP proxy対象外。専用bastion/proxy設計なしにIP allowを追加しない。 |
| Chromium / user script / LibreOffice / Python helper | systemd IP policyが実効なら同じservice cgroupのIP filter対象 | Chromiumにproxyを渡す追加対応なしでは外部閲覧が失敗する。AF_UNIX経由の代理serviceや別cgroupへの処理委譲は別途監査する。子プロセス例は script-runner.ts、office-preview.ts。 |
| Bash sandbox | より内側の別制御 | safety.bash_allow_network はbwrap内だけを切り替える。sandbox.ts 外側のservice境界を緩めない。 |
未設定の fresh config では外部接続が無いことを期待する。LLM worker を設定すると、backend registry は起動し、UI未購読時は既定30秒、購読中は5秒周期でprobeする。server.ts backend-status-registry.ts 許可 policy に worker endpoint が無ければoffline表示になる。この失敗は境界が機能している結果として運用監視で区別する。
オフライン回帰検査
node scripts/security/check-egress-boundary.mjs
検査はすべて loopback と .invalid hostnameを使い、外部本番接続を行わない。
- systemd template が
IPAddressDeny=any、個別/32、空のNO_PROXYを維持すること - Squid template が exact host+port、末尾 default deny、access log無効を維持すること
- 最小環境の実Node 22 childを
--use-env-proxyで起動し、globalfetchがlocal proxyへ実際にCONNECTし、proxyが見たallowed.egress.invalid:443を許可、denied.egress.invalid:443を拒否すること - local policy model がHTTP、CONNECT、WebSocket Upgradeをexact host+portでallow/denyすること
- secret canaryが保存するmetadataと最終出力に含まれないこと
node:http、raw TCP、spawn child、IPv4、IPv6、直接DNSがenv proxyだけでは止まらないこと
実Node childには親processのproxy設定、NODE_OPTIONS、資格情報を継承せず、proxyはtunnel内HTTPにもloopbackで応答して外部へ転送しない。最後の項目は、OS境界を適用した検査ではない。HTTP proxy変数だけでは不足することをローカルcanaryで再現し、systemd等のOS境界が必要な理由を固定する検査である。実OSでの強制を証明しない。
実環境で残る検証
staging host では次を完了するまで本番適用済みと判定しない。
- kernel/systemdが
IPAddressDeny/IPAddressAllowを実際にload・attachし、service子processのAF_INET/AF_INET6 socketにも作用すること - HTTP fetchの許可先成功と未許可先拒否
- IPv4 literal、IPv6 literal、AF_INET/AF_INET6の直接DNS、raw TCP、spawn childの拒否。AF_UNIX代理経路は別に確認
- proxy停止時にfail closedとなり、直接接続へfallbackしないこと
- ingress proxyからUI/APIへ到達でき、他sourceからは到達できないこと
- LLM probe・job、OAuth、MCP、Discord、Web Push、webhook、Chromiumなど、利用する機能ごとの互換性
- audit metadataへAPI key、cookie、Authorization、URL query、request/response body、DNS query nameが入らないこと
Docker Compose版は別段階とする。現行 docker-compose.yml は host.docker.internal:host-gateway と通常の外部routeを持つため、env proxyを足すだけでは境界にならない。実装する場合は MAESTROを internal: true networkだけへ置き、egress proxyだけをinternal/external両networkへ接続し、extra_hostsを削除した上でDocker環境のDNS迂回を実測する。