132 lines
14 KiB
Markdown
132 lines
14 KiB
Markdown
# サーバー外向き通信の境界制御
|
|
|
|
更新日: 2026-09-08
|
|
|
|
## 適用状態
|
|
|
|
このリポジトリは、Linux systemd と既存 Squid を使う配布用 template を提供する。
|
|
|
|
- [`maestro-egress.service.example`](../../deploy/egress/systemd/maestro-egress.service.example) は、MAESTRO service cgroupへ適用する IP 制限の staging 用設計例である。対象OSでの強制を検証するまで本番へ配備しない。
|
|
- [`squid.conf`](../../deploy/egress/squid.conf) は許可した hostname と port の組だけを転送する。
|
|
- [`check-egress-boundary.mjs`](../../scripts/security/check-egress-boundary.mjs) は外部へ接続せず、template と proxy policy の回帰検査を行う。
|
|
|
|
**ネットワーク制御の設計例の配布までで、実 OS へ隔離を適用した試験は未実施である。** 調査環境には Docker CLI、Squid、nftables、tcpdump が無いため、Docker Compose 構成や実 host の cgroup-BPF 動作を完成扱いしない。template を install・enable する前に、対象 host の staging 環境で後述の確認が必要である。
|
|
|
|
## 制御モデル
|
|
|
|
```mermaid
|
|
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 を置き換える。
|
|
|
|
```squidconf
|
|
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する」とする。
|
|
|
|
## 導入手順
|
|
|
|
1. `maestro` 専用 user と、MAESTRO、ingress proxy、Squid が専用に使う address を staging host で用意する。
|
|
2. Squid template の `.invalid` ACL を、承認済み exact hostname+port に置き換える。
|
|
3. `squid -k parse -f <path>` で構文を確認し、Squid を `127.0.0.2:3128` に bind する。
|
|
4. systemd unit の `/opt/maestro`、user/group、EnvironmentFile を実環境へ合わせる。
|
|
5. ingress proxy の MAESTRO 向け source address を `127.0.0.3` に固定する。MAESTRO は `127.0.0.4` で listen する。
|
|
6. `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`](../../scripts/server.sh#L123-L147) を呼ばず、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 の集計を得られる。
|
|
|
|
```bash
|
|
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`](../../package.json#L12-L14) の `>=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`](../../src/llm/openai-compat.ts#L1277-L1283)、probe は [`backend-probes.ts`](../../src/engine/backend-probes.ts#L410-L448) で global fetch を使う。 |
|
|
| `node:http` / `node:https` を直接使う依存 | OS 境界で拒否され得る | env proxyだけでは全 transport を覆わない。transport固有agentを確認してから許可する。 |
|
|
| Discord REST / resident WebSocket | 現状は拒否され得る | active binding は起動時にREST照会後、resident WSへ接続する。[`discord-gateway.ts`](../../src/bridge/chat/discord-gateway.ts#L189-L210) [`discord-gateway.ts`](../../src/bridge/chat/discord-gateway.ts#L214-L266) |
|
|
| Web Push | 現状は拒否され得る | `web-push` が独自 HTTPS transport を使うため、proxy対応を個別検証する。[`push-service.ts`](../../src/push-service.ts#L206-L212) |
|
|
| MCP | transportごとに確認が必要 | 接続はSDK transportへ委譲される。[`client-factory.ts`](../../src/mcp/client-factory.ts#L34-L65) |
|
|
| SSRF strict fetch | 現状は拒否され得る | DNS pinning用の明示的 Undici Agentを渡すため、global dispatcherを使わない。[`ssrf-strict.ts`](../../src/net/ssrf-strict.ts#L128-L170) |
|
|
| 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`](../../src/user-folder/script-runner.ts#L1-L16)、[`office-preview.ts`](../../src/bridge/office-preview.ts#L537-L561)。 |
|
|
| Bash sandbox | より内側の別制御 | `safety.bash_allow_network` はbwrap内だけを切り替える。[`sandbox.ts`](../../src/engine/tools/sandbox.ts#L81-L87) 外側のservice境界を緩めない。 |
|
|
|
|
未設定の fresh config では外部接続が無いことを期待する。LLM worker を設定すると、backend registry は起動し、UI未購読時は既定30秒、購読中は5秒周期でprobeする。[`server.ts`](../../src/bridge/server.ts#L674-L687) [`backend-status-registry.ts`](../../src/engine/backend-status-registry.ts#L176-L190) 許可 policy に worker endpoint が無ければoffline表示になる。この失敗は境界が機能している結果として運用監視で区別する。
|
|
|
|
## オフライン回帰検査
|
|
|
|
```bash
|
|
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`で起動し、global `fetch`が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`](../../docker-compose.yml#L25-L39) は `host.docker.internal:host-gateway` と通常の外部routeを持つため、env proxyを足すだけでは境界にならない。実装する場合は MAESTROを `internal: true` networkだけへ置き、egress proxyだけをinternal/external両networkへ接続し、`extra_hosts`を削除した上でDocker環境のDNS迂回を実測する。
|