diff --git a/README.md b/README.md index 10d8784..3de036e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ LinuxClub の毎日 Python コーディング練習サービス。 | Frontend | Next.js 16 (App Router) / TypeScript / Tailwind CSS v4 | | Backend | Go / Echo v5 | | DB | PostgreSQL 17 | -| コード実行 | Docker (`python:3.14-slim`) | +| コード実行 | Docker (`python:3.14-slim`) / snekbox (nsjail) | | 認証 | OAuth2 Authorization Code Flow + HttpOnly Cookie (JWT) | | バージョン管理 | mise | @@ -40,7 +40,19 @@ cd frontend && pnpm install && cd .. ### 3. 環境変数の設定 -バックエンドは起動時に環境変数を読みます。`backend/.env.example` をコピーして `backend/.env` を作成し、値を設定してください。 +バックエンドは起動時に環境変数を読みます。ローカル開発は `.env` ファイルか shell で設定してください。 + +#### コード実行エンジンの選択 + +| 環境変数 | 値 | 説明 | +|---|---|---| +| `EXECUTOR_TYPE` | `docker`(デフォルト) | Docker コンテナで Python を実行 | +| `EXECUTOR_TYPE` | `snekbox` | snekbox (nsjail) 経由で Python を実行 | +| `SNEKBOX_URL` | `http://snekbox:8060`(デフォルト) | snekbox サービスの URL | + +snekbox を使う場合は `docker-compose.snekbox.yml` も参照してください(後述)。 + +#### 最小構成(認証バイパスあり) ```bash cp backend/.env.example backend/.env @@ -182,6 +194,51 @@ mise run frontend | `mise run db:reset` | データ全削除 → DB 再起動 → マイグレーション | | `mise run backend` | Go API サーバー起動 (port 8080) | | `mise run frontend` | Next.js 開発サーバー起動 (port 3000) | +| `mise run snekbox:dev` | snekbox をローカル開発用に起動(ポート 8060 をホストに公開) | +| `mise run snekbox:dev:down` | snekbox 開発コンテナを停止 | +| `mise run snekbox:up` | snekbox executor で本番スタックを起動 | +| `mise run snekbox:down` | snekbox を含む全サービスを停止 | +| `mise run snekbox:logs` | snekbox のログをリアルタイム表示 | + +### snekbox 実行エンジン(オプション) + +[snekbox](https://github.com/python-discord/snekbox) は nsjail によるシステムコールレベルのサンドボックスを提供します。 +Docker executor より強固なコンテナエスケープ対策が必要な場合に使用します。 + +| 項目 | Docker executor | snekbox | +|---|---|---| +| 分離方式 | Docker コンテナ | nsjail (seccomp-bpf + namespaces) | +| メモリ制限 | 64MB | snekbox 設定で制御 | +| 要 privileged | 不要 | snekbox コンテナに必要(nsjail の要件) | + +#### ローカル開発環境で snekbox を使う + +開発時は Go バックエンドがホストで動くため、snekbox のポートをホストに公開します。 + +```bash +# 1. snekbox コンテナを起動(ポート 8060 をホストに公開) +mise run snekbox:dev + +# 2. backend/.env に以下を追加 +EXECUTOR_TYPE=snekbox +SNEKBOX_URL=http://localhost:8060 + +# 3. バックエンドを通常通り起動 +mise run backend +``` + +停止するときは: + +```bash +mise run snekbox:dev:down +``` + +#### 本番環境で snekbox を使う + +```bash +mise run snekbox:up # snekbox + バックエンドをオーバーライドして起動 +mise run snekbox:down # snekbox を含む全サービスを停止 +``` --- @@ -207,7 +264,9 @@ mise run frontend ``` . ├── mise.toml # ツールバージョン・タスク定義 -├── docker-compose.yml # PostgreSQL +├── docker-compose.yml # PostgreSQL +├── docker-compose.snekbox.yml # snekbox 本番用オーバーライド +├── docker-compose.snekbox.dev.yml # snekbox 開発用オーバーライド(ポート公開) ├── backend/ │ ├── cmd/server/ # エントリポイント │ ├── internal/ @@ -217,7 +276,8 @@ mise run frontend │ │ ├── usecase/ # ビジネスロジック │ │ └── infrastructure/ │ │ ├── db/ # PostgreSQL(migrations・repository) -│ │ └── docker/ # コード実行エンジン +│ │ ├── docker/ # Docker コード実行エンジン +│ │ └── snekbox/ # snekbox (nsjail) コード実行エンジン └── frontend/ └── src/ ├── app/ # Next.js App Router ページ diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 510c3de..d61f6f6 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -18,6 +18,7 @@ import ( "daileycoding/backend/internal/infrastructure/db" "daileycoding/backend/internal/infrastructure/db/repository" oidcinfra "daileycoding/backend/internal/infrastructure/oidc" + snekboxexec "daileycoding/backend/internal/infrastructure/snekbox" appmw "daileycoding/backend/internal/middleware" "daileycoding/backend/internal/usecase" ) @@ -60,14 +61,26 @@ func main() { } // ── コード実行エンジン ── - executor, err := dockerexec.NewExecutor(image) - if err != nil { - log.Fatalf("init docker executor: %v", err) - } - pullCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - if err := executor.EnsureImage(pullCtx); err != nil { - log.Fatalf("ensure image: %v", err) + // EXECUTOR_TYPE=snekbox のとき snekbox HTTP API を使う。 + // 未設定または "docker" のときは従来の Docker コンテナ方式を使う。 + var executor usecase.Executor + switch getEnv("EXECUTOR_TYPE", "docker") { + case "snekbox": + snekboxURL := getEnv("SNEKBOX_URL", "http://snekbox:8060") + executor = snekboxexec.NewExecutor(snekboxURL) + log.Printf("executor: snekbox (%s)", snekboxURL) + default: + dockerExecutor, err := dockerexec.NewExecutor(image) + if err != nil { + log.Fatalf("init docker executor: %v", err) + } + pullCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + if err := dockerExecutor.EnsureImage(pullCtx); err != nil { + log.Fatalf("ensure image: %v", err) + } + executor = dockerExecutor + log.Printf("executor: docker (%s)", image) } // ── OIDC プロバイダー設定 ── diff --git a/backend/internal/infrastructure/snekbox/executor.go b/backend/internal/infrastructure/snekbox/executor.go new file mode 100644 index 0000000..58450c7 --- /dev/null +++ b/backend/internal/infrastructure/snekbox/executor.go @@ -0,0 +1,120 @@ +// Package snekbox は snekbox (https://github.com/python-discord/snekbox) を +// バックエンドとして使うコード実行エンジンを提供する。 +// +// snekbox は nsjail でサンドボックス化された Python 実行環境であり、 +// Docker コンテナ内で動く標準的なアプローチより強固なプロセス分離を提供する。 +// +// セキュリティモデル: +// - nsjail によるシステムコール制限 (seccomp-bpf) +// - namespaces によるプロセス・ファイルシステム・ネットワーク分離 +// - メモリ・CPU・プロセス数の上限は snekbox 側の設定で制御 +// +// API: POST http:///eval +// +// Request: {"input": ""} +// Response: {"stdout": "...", "returncode": 0, "files": []} +package snekbox + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "time" + + "daileycoding/backend/internal/domain" +) + +// Executor は snekbox HTTP API を呼び出してコードを実行する。 +// usecase.Executor インターフェースを実装する。 +type Executor struct { + baseURL string + httpClient *http.Client +} + +// NewExecutor は snekbox Executor を生成する。 +// baseURL: snekbox サービスの URL (例: "http://snekbox:8060") +func NewExecutor(baseURL string) *Executor { + return &Executor{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +type evalRequest struct { + Input string `json:"input"` +} + +type evalResponse struct { + Stdout string `json:"stdout"` + Returncode int `json:"returncode"` +} + +// Execute はコードを snekbox に送信して実行し結果を返す。 +// +// stdin の扱い: +// snekbox API は stdin を直接サポートしないため、コードの先頭に +// sys.stdin を StringIO で差し替えるシムを注入する。 +// stdin は base64 エンコードして埋め込むことでエスケープ問題を回避する。 +func (e *Executor) Execute(ctx context.Context, req domain.ExecuteRequest) (domain.ExecuteResult, error) { + code := req.Code + if req.Stdin != "" { + code = injectStdin(req.Code, req.Stdin) + } + + body, err := json.Marshal(evalRequest{Input: code}) + if err != nil { + return domain.ExecuteResult{}, fmt.Errorf("marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, + e.baseURL+"/eval", bytes.NewReader(body)) + if err != nil { + return domain.ExecuteResult{}, fmt.Errorf("create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + start := time.Now() + resp, err := e.httpClient.Do(httpReq) + if err != nil { + return domain.ExecuteResult{}, fmt.Errorf("call snekbox: %w", err) + } + defer resp.Body.Close() + elapsed := float64(time.Since(start).Milliseconds()) + + if resp.StatusCode != http.StatusOK { + return domain.ExecuteResult{}, fmt.Errorf("snekbox returned status %d", resp.StatusCode) + } + + var evalResp evalResponse + if err := json.NewDecoder(resp.Body).Decode(&evalResp); err != nil { + return domain.ExecuteResult{}, fmt.Errorf("decode response: %w", err) + } + + // snekbox は stdout に実行出力を返す。 + // returncode != 0 の場合は実行エラー扱いとし Stderr に出力を移す。 + result := domain.ExecuteResult{TimeMs: elapsed} + if evalResp.Returncode == 0 { + result.Stdout = evalResp.Stdout + } else { + result.Stderr = evalResp.Stdout + } + return result, nil +} + +// injectStdin はユーザーコードの先頭に stdin モックシムを注入する。 +// stdin の内容は base64 エンコードして埋め込むためエスケープが不要。 +func injectStdin(code, stdin string) string { + encoded := base64.StdEncoding.EncodeToString([]byte(stdin)) + shim := fmt.Sprintf( + "import sys as __sys__,io as __io__,base64 as __b64__\n"+ + "__sys__.stdin=__io__.StringIO(__b64__.b64decode('%s').decode())\n"+ + "del __sys__,__io__,__b64__\n", + encoded, + ) + return shim + code +} diff --git a/docker-compose.snekbox.dev.yml b/docker-compose.snekbox.dev.yml new file mode 100644 index 0000000..c1aa2c4 --- /dev/null +++ b/docker-compose.snekbox.dev.yml @@ -0,0 +1,29 @@ +# ───────────────────────────────────────────────────────────────── +# docker-compose.snekbox.dev.yml snekbox 開発環境用オーバーライド +# +# ホストで動く Go バックエンドから snekbox へ接続できるよう +# ポート 8060 をローカルホストに公開する。 +# +# 使用方法: +# docker compose -f docker-compose.yml -f docker-compose.snekbox.dev.yml up -d +# または: mise run snekbox:dev +# +# バックエンドの .env に以下を追加: +# EXECUTOR_TYPE=snekbox +# SNEKBOX_URL=http://localhost:8060 +# ───────────────────────────────────────────────────────────────── + +services: + + snekbox: + image: ghcr.io/python-discord/snekbox:latest + restart: unless-stopped + privileged: true # nsjail が namespace・seccomp を設定するために必要 + ipc: none # IPC namespace を隔離 + ports: + - "127.0.0.1:8060:8060" # ホストの backend からアクセス可能(外部非公開) + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8060/eval')\" 2>/dev/null || true"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/docker-compose.snekbox.yml b/docker-compose.snekbox.yml new file mode 100644 index 0000000..9991bbe --- /dev/null +++ b/docker-compose.snekbox.yml @@ -0,0 +1,40 @@ +# ───────────────────────────────────────────────────────────────── +# docker-compose.snekbox.yml snekbox 実行エンジン用オーバーライド +# +# docker-compose.prod.yml と組み合わせて使用する: +# docker compose -f docker-compose.prod.yml -f docker-compose.snekbox.yml up -d +# +# snekbox について: +# https://github.com/python-discord/snekbox +# nsjail によるシステムコール制限・namespace 分離を使い、 +# 標準 Docker コンテナよりも強固な Python 実行サンドボックスを提供する。 +# +# セキュリティ上の注意: +# snekbox コンテナは nsjail 起動のため privileged: true が必要。 +# しかし nsjail 内でのコード実行は厳格に制限されているため、 +# これはトレードオフとして受け入れられる設計になっている。 +# ───────────────────────────────────────────────────────────────── + +services: + + snekbox: + image: ghcr.io/python-discord/snekbox:latest + restart: unless-stopped + privileged: true # nsjail が namespace・seccomp を設定するために必要 + ipc: none # IPC namespace を隔離 + networks: + - internal + # snekbox はポート 8060 でリッスン (内部ネットワークのみ・外部非公開) + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8060/eval')\" 2>/dev/null || true"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + environment: + EXECUTOR_TYPE: snekbox + SNEKBOX_URL: http://snekbox:8060 + depends_on: + snekbox: + condition: service_healthy diff --git a/mise.toml b/mise.toml index aae7bbd..7d4231c 100644 --- a/mise.toml +++ b/mise.toml @@ -27,3 +27,23 @@ description = "Start Next.js dev server (port 3000)" env = {_.file = "frontend/.env"} run = "pnpm dev" dir = "frontend" + +[tasks."snekbox:dev"] +description = "Start snekbox for local development (exposes port 8060 to host)" +run = "docker compose -f docker-compose.yml -f docker-compose.snekbox.dev.yml up -d" + +[tasks."snekbox:dev:down"] +description = "Stop snekbox dev container" +run = "docker compose -f docker-compose.yml -f docker-compose.snekbox.dev.yml down" + +[tasks."snekbox:up"] +description = "Start production stack with snekbox executor (overrides docker-compose.prod.yml)" +run = "docker compose -f docker-compose.prod.yml -f docker-compose.snekbox.yml up -d" + +[tasks."snekbox:down"] +description = "Stop all services including snekbox" +run = "docker compose -f docker-compose.prod.yml -f docker-compose.snekbox.yml down" + +[tasks."snekbox:logs"] +description = "Tail snekbox service logs" +run = "docker compose -f docker-compose.prod.yml -f docker-compose.snekbox.yml logs -f snekbox"