0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

License System Day 15: サーバーサイドの実装(Nim/Jester)

Last updated at Posted at 2025-12-14

🎄 科学と神々株式会社 アドベントカレンダー 2025

License System Day 15: サーバーサイドの実装(Nim/Jester)


📖 今日のテーマ

今日は、Nimプログラミング言語とJesterフレームワークを使ったサーバーサイド実装を学びます。

高性能でメモリ効率に優れたNimと、シンプルなJesterフレームワークの組み合わせで、本番環境で使えるライセンスサーバーを構築します。


🎯 なぜNimとJesterなのか?

Nimの利点

✅ ネイティブコード生成 → C/C++並みの高速動作
✅ メモリ安全性 → ARCによる自動メモリ管理
✅ 低メモリ使用量 → 数MB以下で起動
✅ 小さいバイナリ → 1MB以下にコンパイル可能
✅ クロスプラットフォーム → Windows/macOS/Linux対応
✅ WebAssembly対応 → ブラウザ拡張機能に最適
✅ GC-safe保証 → スレッドセーフな非同期処理(Nim 2.2.6+)
✅ グレースフルシャットダウン → 本番環境での安全な停止

本実装のGC-safe設計(Nim 2.2.6対応)

{.threadvar.} プラグマの使用:

Jesterの非同期ルートハンドラ内でグローバル変数にアクセスする際、GC-safeチェックを満たすため、{.threadvar.} プラグマを使用しています。

# ❌ 古い実装(GC-unsafe警告が出る)
let db = newDatabase("path")
let cryptoService = newCryptoService()

# ✅ 新しい実装(GC-safe対応)
var db {.threadvar.}: Database
var cryptoService {.threadvar.}: CryptoService

{.threadvar.} の効果:

  • スレッドローカル変数として宣言
  • 非同期コンテキストからの安全なアクセスを保証
  • Jesterはシングルスレッド (--threads:off) なので、実質的にグローバル変数と同じ動作
  • GC-safeチェックをパス(コンパイル警告なし)

Jesterフレームワークの特徴

✅ シンプルなルーティング → Sinatraライクな記法
✅ 非同期処理対応 → async/awaitサポート
✅ HTTPサーバー内蔵 → 追加サーバー不要
✅ ミドルウェア対応 → 認証・ロギング統合
✅ JSONサポート → REST APIに最適

🏗️ プロジェクト構造

server/
├── src/
│   ├── main.nim             # メインエントリーポイント(ユーティリティ関数含む)
│   ├── crypto.nim           # 暗号化・署名機能(パスワードハッシュ含む)
│   └── database.nim         # データベース操作
├── tests/
│   └── test_server.nim      # テストコード
├── bin/
│   └── license_server       # コンパイル済みバイナリ
├── license_system_server.nimble  # プロジェクト設定
└── nim.cfg                  # コンパイラ設定

: 本実装では、ユーティリティ関数(UUID生成、パスワードハッシュなど)を個別ファイルに分離せず、機能ごとに統合しています:

  • hashPassword(), verifyPassword() → crypto.nim に統合
  • generateUUID() → main.nim に統合

💻 メインサーバー実装

main.nim(完全版)

# server/src/main.nim
import std/[json, strutils, times, random, os, posix]
import pkg/jester
import crypto, database
import ../../shared/types

randomize()

# Global instances (thread-local for GC-safe async access)
# Note: Jester runs in single-threaded mode (--threads:off), so threadvar behaves like global
var db {.threadvar.}: Database
var cryptoService {.threadvar.}: CryptoService
var isShuttingDown {.threadvar.}: bool

# === ユーティリティ関数(main.nim統合版) ===

proc generateUUID(): string =
  ## シンプルなUUID生成
  randomize()
  result = ""
  for i in 0..<32:
    if i in [8, 12, 16, 20]:
      result.add("-")
    result.add(toHex(rand(15), 1).toLowerAscii())

# 起動メッセージ
echo """
🚀 License Server Starting
============================================================
📍 Server: http://localhost:3000
🔐 Crypto: ECDSA P-256 (placeholder)
📊 Database: SQLite3
🌐 Environment: development
============================================================

📝 Demo Accounts:
  Free Tier:       free@example.com / password123
  Premium Tier:    premium@example.com / password123
  Enterprise Tier: enterprise@example.com / password123
"""

# ルーティング定義
routes:
  # ========================================
  # ヘルスチェック(グレースフルシャットダウン対応)
  # ========================================
  get "/health":
    ## サーバーヘルスチェック
    ## シャットダウン中は503を返す(Kubernetes/Docker対応)
    if isShuttingDown:
      resp Http503, $(%*{
        "status": "shutting_down",
        "timestamp": $now(),
        "message": "Server is gracefully shutting down"
      })
    else:
      resp Http200, $(%*{
        "status": "ok",
        "timestamp": $now()
      })

  # ========================================
  # ライセンス認証(アクティベーション)
  # ========================================
  post "/api/v1/license/activate":
    ## ライセンス認証エンドポイント
    ##
    ## Request:
    ##   {
    ##     "email": "user@example.com",
    ##     "password": "password123",
    ##     "client_id": "browser-extension-001"
    ##   }
    ##
    ## Response:
    ##   {
    ##     "status": "activated",
    ##     "activation_key": "JWT_TOKEN",
    ##     "plan_type": "premium_monthly",
    ##     "signature": "ECDSA_SIGNATURE"
    ##   }

    try:
      # リクエストボディのパース
      let body = parseJson(request.body)
      let email = body["email"].getStr()
      let password = body["password"].getStr()
      let clientId = body["client_id"].getStr()

      # ユーザー認証
      let userOpt = db.getUserByEmail(email)
      if userOpt.isNone:
        resp Http401, %*{
          "error": "Invalid credentials",
          "message": "Email or password is incorrect"
        }

      let user = userOpt.get()
      let userId = user[0]
      let storedHash = user[2]
      let isActive = user[3] == "1"

      # パスワード検証(crypto.nimのverifyPassword使用)
      if not cryptoService.verifyPassword(password, storedHash):
        resp Http401, %*{
          "error": "Invalid credentials",
          "message": "Email or password is incorrect"
        }

      # アカウント有効性チェック
      if not isActive:
        resp Http403, %*{
          "error": "Account disabled",
          "message": "Your account has been disabled. Please contact support."
        }

      # アクティブなサブスクリプション取得
      let subscriptionOpt = db.getActiveSubscription(userId)
      if subscriptionOpt.isNone:
        resp Http402, %*{
          "error": "No active subscription",
          "message": "Please subscribe to a plan to activate license"
        }

      let subscription = subscriptionOpt.get()
      let subscriptionId = subscription[0]
      let planType = parseEnum[PlanType](subscription[1])
      let endDate = parse(subscription[2], "yyyy-MM-dd HH:mm:ss")

      # サブスクリプション期限チェック
      if now() > endDate:
        resp Http402, %*{
          "error": "Subscription expired",
          "message": "Your subscription has expired. Please renew."
        }

      # ライセンス作成または取得(generateUUID使用)
      let licenseId = generateUUID()
      let activationKey = cryptoService.generateJWT(
        userId = userId,
        email = email,
        planType = $planType,
        expiresIn = 365  # 1年間有効
      )

      # ライセンスをDBに保存
      let success = db.createLicense(
        licenseId = licenseId,
        subscriptionId = subscriptionId,
        clientId = clientId,
        activationKey = activationKey
      )

      if not success:
        resp Http500, %*{"error": "Failed to create license"}

      # レスポンス作成
      let responseData = %*{
        "status": "activated",
        "activation_key": activationKey,
        "license_id": licenseId,
        "plan_type": $planType,
        "expires_at": endDate.format("yyyy-MM-dd HH:mm:ss"),
        "features": getPlanFeatures(planType)
      }

      # デジタル署名を追加
      let signature = cryptoService.signData(responseData)
      responseData["signature"] = %signature

      resp Http200, responseData

    except JsonParsingError:
      resp Http400, %*{
        "error": "Invalid JSON",
        "message": "Request body must be valid JSON"
      }
    except Exception as e:
      resp Http500, %*{
        "error": "Internal server error",
        "message": e.msg
      }

  # ========================================
  # ライセンス検証
  # ========================================
  get "/api/v1/license/validate":
    ## ライセンス検証エンドポイント
    ##
    ## Headers:
    ##   X-Activation-Key: JWT_TOKEN
    ##
    ## Response:
    ##   {
    ##     "status": "valid",
    ##     "premium": true,
    ##     "plan_type": "premium_monthly",
    ##     "limits": {...},
    ##     "features": {...}
    ##   }

    # アクティベーションキー取得
    if not request.headers.hasKey("X-Activation-Key"):
      resp Http401, %*{
        "error": "Missing activation key",
        "message": "X-Activation-Key header is required"
      }

    let token = request.headers["X-Activation-Key"]

    # JWT検証
    let claimsOpt = cryptoService.verifyJWT(token)
    if claimsOpt.isNone:
      resp Http401, %*{
        "error": "Invalid token",
        "message": "Activation key is invalid or expired"
      }

    let claims = claimsOpt.get()
    let userId = claims["user_id"].getStr()
    let planType = parseEnum[PlanType](claims["plan_type"].getStr())
    let exp = claims["exp"].getInt()

    # 有効期限チェック
    if epochTime().int > exp:
      resp Http401, %*{
        "error": "Token expired",
        "message": "Activation key has expired. Please reactivate."
      }

    # ライセンス情報取得
    let licenseOpt = db.getLicenseByKey(token)
    if licenseOpt.isNone:
      resp Http401, %*{
        "error": "License not found",
        "message": "License has been revoked or does not exist"
      }

    let license = licenseOpt.get()
    let isActive = license[5] == "1"

    if not isActive:
      resp Http403, %*{
        "error": "License revoked",
        "message": "This license has been revoked"
      }

    # バリデーションカウント更新
    db.updateLicenseValidation(token)

    # プラン設定取得
    let limits = getPlanLimits(planType)
    let features = getPlanFeatures(planType)

    # レスポンス作成
    let responseData = %*{
      "status": "valid",
      "premium": planType != ptFree,
      "plan_type": $planType,
      "user_id": userId,
      "limits": limits,
      "features": features,
      "validated_at": now().format("yyyy-MM-dd HH:mm:ss")
    }

    # 署名追加
    let signature = cryptoService.signData(responseData)
    responseData["signature"] = %signature

    resp Http200, responseData

  # ========================================
  # エコーサービス(レート制限付き)
  # ========================================
  post "/api/v1/service/echo":
    ## エコーサービス - レートリミット適用
    ##
    ## Headers:
    ##   X-Activation-Key: JWT_TOKEN (optional)
    ##
    ## Request:
    ##   {"message": "Hello, World!"}
    ##
    ## Response:
    ##   {
    ##     "echo": "[PREMIUM] HELLO, WORLD!",
    ##     "plan": "premium_monthly",
    ##     "rate_limit": {...}
    ##   }

    var userId = "anonymous"
    var planType = ptFree
    var limits = getPlanLimits(ptFree)

    # JWT検証(任意)
    if request.headers.hasKey("X-Activation-Key"):
      let token = request.headers["X-Activation-Key"]
      let claimsOpt = cryptoService.verifyJWT(token)

      if claimsOpt.isSome:
        let claims = claimsOpt.get()
        userId = claims["user_id"].getStr()
        planType = parseEnum[PlanType](claims["plan_type"].getStr())
        limits = getPlanLimits(planType)

    # レートリミットチェック
    if not db.checkRateLimit(userId, "echo", limits.maxRequestsPerHour):
      let status = db.getRateLimitStatus(userId, "echo", limits.maxRequestsPerHour)

      resp Http429, [
        ("X-RateLimit-Limit", $limits.maxRequestsPerHour),
        ("X-RateLimit-Remaining", "0"),
        ("X-RateLimit-Reset", status.resetAt.format("yyyy-MM-dd'T'HH:mm:ss'Z'"))
      ], %*{
        "error": "Rate limit exceeded",
        "message": "Too many requests. Please upgrade your plan or try again later.",
        "rate_limit": {
          "limit": limits.maxRequestsPerHour,
          "remaining": 0,
          "reset_at": status.resetAt.format("yyyy-MM-dd HH:mm:ss")
        },
        "upgrade_url": "https://example.com/upgrade"
      }

    # リクエスト処理
    let body = parseJson(request.body)
    let message = body["message"].getStr()

    # メッセージ長チェック
    if message.len > limits.maxMessageLength:
      resp Http413, %*{
        "error": "Message too long",
        "message": "Message exceeds plan limit",
        "limit": limits.maxMessageLength,
        "received": message.len
      }

    # エコー処理
    var echoResult = message
    if planType == ptEnterprise:
      echoResult = "[ENTERPRISE] " & message.toUpperAscii() & " [PRIORITY]"
    elif planType == ptPremium:
      echoResult = "[PREMIUM] " & message.toUpperAscii()
    else:
      echoResult = "[FREE] " & message

    # レート制限情報を取得
    let status = db.getRateLimitStatus(userId, "echo", limits.maxRequestsPerHour)

    # レスポンス
    resp Http200, [
      ("X-RateLimit-Limit", $limits.maxRequestsPerHour),
      ("X-RateLimit-Remaining", $status.remaining),
      ("X-RateLimit-Reset", status.resetAt.format("yyyy-MM-dd'T'HH:mm:ss'Z'"))
    ], %*{
      "echo": echoResult,
      "plan": $planType,
      "rate_limit": {
        "limit": limits.maxRequestsPerHour,
        "remaining": status.remaining,
        "reset_at": status.resetAt.format("yyyy-MM-dd HH:mm:ss")
      }
    }

  # ========================================
  # プラン一覧
  # ========================================
  get "/api/v1/plans":
    ## プラン一覧取得
    let plans = comparePlans()
    resp Http200, plans

# グレースフルシャットダウン実装
proc cleanup() =
  ## Cleanup resources before shutdown
  echo ""
  echo "🛑 Shutting down gracefully..."
  stdout.flushFile()

  # Close database connection
  try:
    echo "📦 Closing database connection..."
    stdout.flushFile()
    db.close()
    echo "✅ Database connection closed"
    stdout.flushFile()
  except Exception as e:
    echo "⚠️  Error closing database: " & e.msg
    stdout.flushFile()

  echo "✅ Cleanup completed"
  stdout.flushFile()

proc handleSignal(sig: cint) {.noconv.} =
  ## Signal handler for SIGTERM and SIGINT
  if isShuttingDown:
    echo ""
    echo "⚠️  Force shutdown requested, exiting immediately..."
    stdout.flushFile()
    quit(1)

  isShuttingDown = true
  echo ""
  echo "📡 Received shutdown signal (", sig, ")"
  stdout.flushFile()
  cleanup()
  quit(0)

proc setupSignalHandlers() =
  ## Setup signal handlers for graceful shutdown
  # Setup SIGINT handler (Ctrl+C)
  setControlCHook(proc() {.noconv.} =
    handleSignal(SIGINT)
  )

# Setup signal handlers
setupSignalHandlers()
echo "✅ Signal handlers configured (Ctrl+C for graceful shutdown)"
stdout.flushFile()

# サーバー起動
runForever()

🔐 暗号化モジュール(crypto.nim)

: このファイルにはパスワードハッシュ関数(hashPassword, verifyPassword)も統合されています。

# server/src/crypto.nim
import std/[json, times, base64, hmac, sha, options]
import nimcrypto
import bcrypt  # パスワードハッシュ用

type
  CryptoService* = ref object
    privateKey: string
    publicKey: string
    jwtSecret: string

proc newCryptoService*(privateKeyPath, publicKeyPath: string): CryptoService =
  result = CryptoService(
    privateKey: readFile(privateKeyPath),
    publicKey: readFile(publicKeyPath),
    jwtSecret: "your-secret-key-replace-in-production"
  )

# === パスワードハッシュ関数(crypto.nim統合版) ===

proc hashPassword*(password: string): string =
  ## bcryptでパスワードハッシュ化
  result = hash(password, generateSalt(10))

proc verifyPassword*(password, hash: string): bool =
  ## パスワード検証
  result = compare(password, hash)

# === JWT関連機能 ===

proc generateJWT*(self: CryptoService, userId, email, planType: string, expiresIn: int = 365): string =
  ## JWT生成(有効期限: 日数)
  let now = epochTime().int
  let exp = now + (expiresIn * 24 * 3600)

  # ヘッダー
  let header = %*{
    "alg": "HS256",
    "typ": "JWT"
  }

  # ペイロード
  let payload = %*{
    "user_id": userId,
    "email": email,
    "plan_type": planType,
    "iat": now,
    "exp": exp
  }

  # Base64URL エンコード
  let headerEncoded = encode($header).replace("+", "-").replace("/", "_").replace("=", "")
  let payloadEncoded = encode($payload).replace("+", "-").replace("/", "_").replace("=", "")

  # 署名生成
  let message = headerEncoded & "." & payloadEncoded
  var hmacCtx: HMAC[sha256]
  hmacCtx.init(self.jwtSecret)
  hmacCtx.update(message)
  let signature = encode(hmacCtx.finish().data).replace("+", "-").replace("/", "_").replace("=", "")

  # JWT完成
  result = message & "." & signature

proc verifyJWT*(self: CryptoService, token: string): Option[JsonNode] =
  ## JWT検証
  try:
    let parts = token.split(".")
    if parts.len != 3:
      return none(JsonNode)

    # 署名検証
    let message = parts[0] & "." & parts[1]
    var hmacCtx: HMAC[sha256]
    hmacCtx.init(self.jwtSecret)
    hmacCtx.update(message)
    let expectedSignature = encode(hmacCtx.finish().data).replace("+", "-").replace("/", "_").replace("=", "")

    if parts[2] != expectedSignature:
      return none(JsonNode)

    # ペイロード復号化
    let payloadDecoded = decode(parts[1].replace("-", "+").replace("_", "/"))
    let payload = parseJson(payloadDecoded)

    # 有効期限チェック
    let exp = payload["exp"].getInt()
    if epochTime().int > exp:
      return none(JsonNode)

    return some(payload)

  except:
    return none(JsonNode)

proc signData*(self: CryptoService, data: JsonNode): string =
  ## データ署名(ECDSA P-256プレースホルダー)
  ##
  ## 本番環境では実際のECDSA署名を実装してください
  let canonical = $data
  var hmacCtx: HMAC[sha256]
  hmacCtx.init("server-secret-key-replace-with-ecdsa")
  hmacCtx.update(canonical)
  let digest = hmacCtx.finish()
  result = encode(digest.data)

🛑 グレースフルシャットダウン

本番環境での安全な停止を実現するため、シグナルハンドラとクリーンアップ処理を実装しています(上記 main.nim のコード内に統合済み)。

グレースフルシャットダウンの動作:

  1. SIGINT (Ctrl+C) 受信時:

    • isShuttingDown フラグを true に設定
    • /health エンドポイントが 503 を返すようになる
    • データベース接続を安全に閉じる
    • サーバーを終了
  2. Kubernetes/Docker対応:

    • ヘルスチェックで 503 を返すことで、トラフィックを他のインスタンスへ流す
    • クリーンな停止によりデータ損失を防ぐ

🌟 まとめ

Nim/Jester サーバー実装の要点:

  1. Nimの利点

    • ネイティブコード性能
    • 低メモリ使用量(~10MB)
    • WebAssembly対応
    • GC-safe保証(Nim 2.2.6+)
  2. Jesterフレームワーク

    • シンプルなルーティング
    • 非同期対応
    • RESTful API
    • {.threadvar.} プラグマでGC-safe対応
  3. 実装機能

    • JWT認証
    • レートリミット
    • プラン管理
    • グレースフルシャットダウン
  4. セキュリティ

    • bcryptパスワード(crypto.nimに統合)
    • ECDSA署名(プレースホルダー)
    • トークン検証
  5. 本番環境対応

    • シグナルハンドリング(SIGINT/Ctrl+C)
    • データベース接続の安全なクローズ
    • Kubernetes/Docker対応のヘルスチェック
  6. コード構成

    • ユーティリティ関数を個別ファイルに分離せず機能ごとに統合
    • hashPassword(), verifyPassword() → crypto.nim
    • generateUUID() → main.nim
    • シンプルで保守しやすい3ファイル構成(main.nim, crypto.nim, database.nim)

前回: Day 14: 階層型プランの設計
次回: Day 16: クライアントサイドの実装(WebAssembly)

Happy Learning! 🎉

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?