Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 64 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 を含む全サービスを停止
```

---

Expand All @@ -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/
Expand All @@ -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 ページ
Expand Down
29 changes: 21 additions & 8 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 プロバイダー設定 ──
Expand Down
120 changes: 120 additions & 0 deletions backend/internal/infrastructure/snekbox/executor.go
Original file line number Diff line number Diff line change
@@ -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://<host>/eval
//
// Request: {"input": "<python code>"}
// 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
}
29 changes: 29 additions & 0 deletions docker-compose.snekbox.dev.yml
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions docker-compose.snekbox.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"