Garnish

SSHベースアプリケーション用TUIフレームワーク。Ratatouilleのviewアーキテクチャをベースに、Erlangのsshフレームワークに適応。セキュアなリモートアクセスを提供。

TUITerminalElixirSSHRemote-Access

Garnish

GarnishはSSH接続を通じてアクセスされるアプリケーション用のTUIフレームワークです。Ratatouilleのビューアーキテクチャとイベント処理システムをベースとして構築されており、Erlang/OTPのSSHフレームワークに適応することで、セキュアなリモートターミナルアプリケーションの開発を可能にします。

特徴

SSH特化アーキテクチャ

  • SSH統合: Erlang/OTPのSSHフレームワークとの深い統合
  • リモートアクセス: ネットワーク越しのTUIアプリケーション実行
  • セッション管理: マルチユーザー・マルチセッションサポート
  • 認証: SSH標準の認証機構を利用

Ratatouilleベースの設計

  • ビューアーキテクチャ: Ratatouilleと同様のビュー構築システム
  • 宣言的UI: HTMLライクなDSLでユーザーインターフェース構築
  • イベント処理: キーボード、マウスイベントの統一処理
  • コンポーネント: 再利用可能なUIコンポーネント

セキュリティ機能

  • 暗号化通信: SSH暗号化によるセキュアな通信
  • 認証制御: ユーザー認証とアクセス制御
  • セッション分離: ユーザーセッションの独立性保証
  • 監査ログ: アクセスログと操作履歴の記録

基本的な使用方法

インストール

# mix.exs
defp deps do
  [
    {:garnish, "~> 0.1.0"},
    {:ssh, ">= 4.10.0"}
  ]
end
mix deps.get

基本的なSSH TUIサーバー

defmodule MyApp.SSHServer do
  use Garnish.SSHApp

  import Garnish.View

  @impl true
  def init(context) do
    user = context.ssh_user || "anonymous"
    
    %{
      user: user,
      message: "Welcome #{user}!",
      connected_at: DateTime.utc_now(),
      commands: []
    }
  end

  @impl true
  def update(model, msg) do
    case msg do
      {:event, %{ch: ?h}} ->
        help_message = "Available commands: [h] Help, [t] Time, [u] User info, [q] Quit"
        new_commands = [help_message | model.commands]
        %{model | commands: Enum.take(new_commands, 10)}
        
      {:event, %{ch: ?t}} ->
        time_message = "Current time: #{DateTime.utc_now() |> DateTime.to_string()}"
        new_commands = [time_message | model.commands]
        %{model | commands: Enum.take(new_commands, 10)}
        
      {:event, %{ch: ?u}} ->
        user_message = "User: #{model.user}, Connected: #{model.connected_at |> DateTime.to_string()}"
        new_commands = [user_message | model.commands]
        %{model | commands: Enum.take(new_commands, 10)}
        
      {:event, %{key: key}} when key in [:q, :ctrl_c] ->
        Garnish.Runtime.stop_session()
        model
        
      _ -> model
    end
  end

  @impl true
  def render(model) do
    view do
      panel(title: "SSH Terminal Application") do
        row do
          column(size: 12) do
            label(content: model.message, attributes: [color: :green])
          end
        end
        
        row do
          column(size: 12) do
            panel(title: "Command History") do
              if Enum.empty?(model.commands) do
                label(content: "No commands executed yet. Press 'h' for help.")
              else
                Enum.map(model.commands, fn cmd ->
                  label(content: cmd)
                end)
              end
            end
          end
        end
        
        row do
          column(size: 12) do
            label(content: "Commands: [h] Help, [t] Time, [u] User info, [q] Quit")
          end
        end
      end
    end
  end
end

# SSH サーバーを起動
defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    children = [
      {Garnish.SSHServer, [
        app: MyApp.SSHServer,
        port: 2222,
        system_dir: "/path/to/ssh/keys",
        user_dir: "/path/to/user/keys"
      ]}
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

ファイル管理TUIアプリ

defmodule FileManager do
  use Garnish.SSHApp

  import Garnish.View

  def init(context) do
    user_home = context.ssh_user_dir || "/tmp"
    
    %{
      current_path: user_home,
      files: list_files(user_home),
      selected_index: 0,
      user: context.ssh_user
    }
  end

  def update(model, msg) do
    case msg do
      {:event, %{key: :arrow_up}} ->
        new_index = max(0, model.selected_index - 1)
        %{model | selected_index: new_index}
        
      {:event, %{key: :arrow_down}} ->
        max_index = max(0, length(model.files) - 1)
        new_index = min(max_index, model.selected_index + 1)
        %{model | selected_index: new_index}
        
      {:event, %{key: :enter}} ->
        if model.selected_index < length(model.files) do
          selected_file = Enum.at(model.files, model.selected_index)
          file_path = Path.join(model.current_path, selected_file.name)
          
          if selected_file.type == :directory do
            case list_files(file_path) do
              {:ok, files} ->
                %{model | 
                  current_path: file_path,
                  files: files,
                  selected_index: 0
                }
              {:error, _} ->
                model
            end
          else
            model
          end
        else
          model
        end
        
      {:event, %{ch: ?b}} when model.current_path != "/" ->
        parent_path = Path.dirname(model.current_path)
        case list_files(parent_path) do
          {:ok, files} ->
            %{model | 
              current_path: parent_path,
              files: files,
              selected_index: 0
            }
          {:error, _} ->
            model
        end
        
      {:event, %{ch: ?r}} ->
        case list_files(model.current_path) do
          {:ok, files} ->
            %{model | files: files, selected_index: 0}
          {:error, _} ->
            model
        end
        
      {:event, %{key: key}} when key in [:q, :ctrl_c] ->
        Garnish.Runtime.stop_session()
        model
        
      _ -> model
    end
  end

  def render(%{current_path: path, files: files, selected_index: selected, user: user}) do
    file_rows = files
    |> Enum.with_index()
    |> Enum.map(fn {file, index} ->
      attributes = if index == selected, do: [color: :yellow], else: []
      
      icon = case file.type do
        :directory -> "[DIR]"
        :file -> "[FILE]"
        _ -> "[?]"
      end
      
      size_str = case file.size do
        nil -> ""
        size when size > 1024 * 1024 -> "#{div(size, 1024 * 1024)}MB"
        size when size > 1024 -> "#{div(size, 1024)}KB"
        size -> "#{size}B"
      end
      
      row(attributes) do
        column(size: 2) do
          label(content: icon)
        end
        column(size: 6) do
          label(content: file.name)
        end
        column(size: 4) do
          label(content: size_str)
        end
      end
    end)

    view do
      panel(title: "File Manager - #{user}@ssh") do
        row do
          column(size: 12) do
            label(content: "Current: #{path}", attributes: [color: :cyan])
          end
        end
        
        panel(title: "Files") do
          if Enum.empty?(files) do
            label(content: "No files in directory")
          else
            file_rows
          end
        end
        
        row do
          column(size: 12) do
            selected_file = if selected < length(files), do: Enum.at(files, selected), else: nil
            if selected_file do
              label(content: "Selected: #{selected_file.name}")
            end
          end
        end
        
        label(content: "")
        label(content: "[↑↓] Navigate, [Enter] Open, [b] Back, [r] Refresh, [q] Quit")
      end
    end
  end

  defp list_files(path) do
    try do
      files = File.ls!(path)
      |> Enum.map(fn name ->
        file_path = Path.join(path, name)
        stat = File.stat!(file_path, time: :posix)
        
        %{
          name: name,
          type: stat.type,
          size: stat.size,
          modified: stat.mtime
        }
      end)
      |> Enum.sort_by(fn file -> {file.type != :directory, file.name} end)
      
      {:ok, files}
    rescue
      _ -> {:error, :access_denied}
    end
  end
end

システム監視TUIアプリ

defmodule SystemMonitor do
  use Garnish.SSHApp

  import Garnish.View

  def init(context) do
    # 定期的なシステム情報更新
    :timer.send_interval(2000, self(), :update_system_info)
    
    %{
      user: context.ssh_user,
      system_info: get_system_info(),
      processes: get_top_processes(),
      last_updated: DateTime.utc_now()
    }
  end

  def update(model, msg) do
    case msg do
      :update_system_info ->
        %{model |
          system_info: get_system_info(),
          processes: get_top_processes(),
          last_updated: DateTime.utc_now()
        }
        
      {:event, %{ch: ?r}} ->
        %{model |
          system_info: get_system_info(),
          processes: get_top_processes(),
          last_updated: DateTime.utc_now()
        }
        
      {:event, %{key: key}} when key in [:q, :ctrl_c] ->
        Garnish.Runtime.stop_session()
        model
        
      _ -> model
    end
  end

  def render(%{system_info: info, processes: processes, last_updated: updated, user: user}) do
    view do
      panel(title: "System Monitor - #{user}@ssh") do
        row do
          column(size: 6) do
            panel(title: "System Information") do
              label(content: "Hostname: #{info.hostname}")
              label(content: "OS: #{info.os}")
              label(content: "Uptime: #{info.uptime}")
              label(content: "Load: #{info.load_average}")
            end
          end
          
          column(size: 6) do
            panel(title: "Memory & CPU") do
              label(content: "CPU Usage: #{info.cpu_usage}%")
              label(content: "Memory: #{info.memory_used}/#{info.memory_total}")
              label(content: "Disk Usage: #{info.disk_usage}%")
              label(content: "Network: ↑#{info.network_tx} ↓#{info.network_rx}")
            end
          end
        end
        
        panel(title: "Top Processes") do
          row do
            column(size: 3) do
              label(content: "PID", attributes: [color: :cyan])
            end
            column(size: 4) do
              label(content: "Command", attributes: [color: :cyan])
            end
            column(size: 3) do
              label(content: "CPU%", attributes: [color: :cyan])
            end
            column(size: 2) do
              label(content: "MEM%", attributes: [color: :cyan])
            end
          end
          
          Enum.take(processes, 8)
          |> Enum.map(fn proc ->
            row do
              column(size: 3) do
                label(content: to_string(proc.pid))
              end
              column(size: 4) do
                command = String.slice(proc.command, 0, 15)
                label(content: command)
              end
              column(size: 3) do
                label(content: "#{proc.cpu}%")
              end
              column(size: 2) do
                label(content: "#{proc.memory}%")
              end
            end
          end)
        end
        
        row do
          column(size: 12) do
            updated_str = updated |> DateTime.to_string() |> String.slice(0, 19)
            label(content: "Last updated: #{updated_str} UTC")
          end
        end
        
        label(content: "[r] Refresh, [q] Quit")
      end
    end
  end

  defp get_system_info do
    %{
      hostname: get_hostname(),
      os: get_os_info(),
      uptime: get_uptime(),
      load_average: get_load_average(),
      cpu_usage: get_cpu_usage(),
      memory_used: get_memory_used(),
      memory_total: get_memory_total(),
      disk_usage: get_disk_usage(),
      network_tx: get_network_tx(),
      network_rx: get_network_rx()
    }
  end

  defp get_top_processes do
    # システムの上位プロセスを取得(実装例)
    [
      %{pid: 1, command: "systemd", cpu: 0.1, memory: 0.5},
      %{pid: 123, command: "beam.smp", cpu: 5.2, memory: 8.3},
      %{pid: 456, command: "chrome", cpu: 12.4, memory: 15.6},
      %{pid: 789, command: "ssh-daemon", cpu: 0.2, memory: 1.1}
    ]
  end

  # システム情報取得のヘルパー関数(実装例)
  defp get_hostname, do: System.get_env("HOSTNAME") || "localhost"
  defp get_os_info, do: "#{System.get_env("OS") || "Linux"}"
  defp get_uptime, do: "2 days, 4 hours"
  defp get_load_average, do: "0.45, 0.38, 0.32"
  defp get_cpu_usage, do: 15.7
  defp get_memory_used, do: "3.2GB"
  defp get_memory_total, do: "8.0GB"
  defp get_disk_usage, do: 68
  defp get_network_tx, do: "1.2MB/s"
  defp get_network_rx, do: "850KB/s"
end

高度な機能

マルチセッション管理

defmodule MultiSessionApp do
  use Garnish.SSHApp

  import Garnish.View

  def init(context) do
    session_id = context.session_id
    user = context.ssh_user
    
    # セッション情報をグローバルステートに登録
    SessionManager.register_session(session_id, user)
    
    %{
      session_id: session_id,
      user: user,
      connected_users: SessionManager.get_connected_users(),
      messages: [],
      input_buffer: ""
    }
  end

  def update(model, msg) do
    case msg do
      {:broadcast, message} ->
        new_messages = [message | model.messages] |> Enum.take(20)
        %{model | messages: new_messages}
        
      {:event, %{key: :enter}} ->
        if String.trim(model.input_buffer) != "" do
          message = "[#{model.user}] #{model.input_buffer}"
          SessionManager.broadcast_message(message)
          %{model | input_buffer: ""}
        else
          model
        end
        
      {:event, %{key: :backspace}} ->
        new_buffer = String.slice(model.input_buffer, 0..-2)
        %{model | input_buffer: new_buffer}
        
      {:event, %{ch: ch}} when ch > 0 and ch < 127 ->
        new_buffer = model.input_buffer <> <<ch>>
        %{model | input_buffer: new_buffer}
        
      {:event, %{key: key}} when key in [:q, :ctrl_c] ->
        SessionManager.unregister_session(model.session_id)
        Garnish.Runtime.stop_session()
        model
        
      _ -> model
    end
  end

  def render(%{user: user, connected_users: users, messages: messages, input_buffer: buffer}) do
    view do
      panel(title: "Multi-Session Chat") do
        row do
          column(size: 8) do
            panel(title: "Messages") do
              if Enum.empty?(messages) do
                label(content: "No messages yet. Type and press Enter to send.")
              else
                Enum.reverse(messages)
                |> Enum.map(fn msg ->
                  label(content: msg)
                end)
              end
            end
          end
          
          column(size: 4) do
            panel(title: "Connected Users") do
              Enum.map(users, fn connected_user ->
                color = if connected_user == user, do: :yellow, else: :white
                label(content: "• #{connected_user}", attributes: [color: color])
              end)
            end
          end
        end
        
        row do
          column(size: 12) do
            panel(title: "Input") do
              label(content: "#{user}> #{buffer}_")
            end
          end
        end
        
        label(content: "[Enter] Send message, [q] Quit")
      end
    end
  end
end

defmodule SessionManager do
  use Agent

  def start_link(_) do
    Agent.start_link(fn -> %{} end, name: __MODULE__)
  end

  def register_session(session_id, user) do
    Agent.update(__MODULE__, fn sessions ->
      Map.put(sessions, session_id, user)
    end)
  end

  def unregister_session(session_id) do
    Agent.update(__MODULE__, fn sessions ->
      Map.delete(sessions, session_id)
    end)
  end

  def get_connected_users do
    Agent.get(__MODULE__, fn sessions ->
      Map.values(sessions) |> Enum.uniq()
    end)
  end

  def broadcast_message(message) do
    # 全セッションにメッセージをブロードキャスト
    # 実装は Garnish の内部セッション管理に依存
  end
end

エコシステム

関連技術

  • Ratatouille: ベースとなるTUIフレームワーク
  • Erlang SSH: 基盤となるSSH実装
  • OTP: プロセス管理とスーパーバイザー
  • GenServer: セッション管理とステート

セキュリティ考慮

  • SSH暗号化: 全通信の暗号化
  • 認証機構: 公開鍵、パスワード認証
  • アクセス制御: ユーザー権限管理
  • セッション分離: マルチテナント対応

利点

  • リモートアクセス: ネットワーク越しのTUIアプリケーション
  • セキュリティ: SSH標準のセキュリティ機能
  • マルチユーザー: 複数ユーザーの同時接続
  • Elixirの利点: 並行性とフォールトトレランス
  • Ratatouilleベース: 豊富なUIコンポーネント

制約事項

  • 新しいプロジェクト: 開発初期段階のため機能制限
  • ドキュメント: 限定的なドキュメント
  • コミュニティ: 小規模なコミュニティ
  • SSH依存: SSH環境が必要
  • ネットワーク制約: レイテンシーの影響

他のライブラリとの比較

項目GarnishRatatouilleElementTui
接続方式SSHローカルローカル
マルチユーザー××
セキュリティ
成熟度
用途リモート管理ローカルTUI一般的TUI

まとめ

GarnishはSSHベースのTUIアプリケーション開発に特化した革新的なフレームワークです。Ratatouilleの堅実なアーキテクチャを基盤として、SSH接続によるセキュアなリモートアクセス機能を提供します。開発初期段階のプロジェクトですが、リモート管理ツール、マルチユーザーターミナルアプリケーション、セキュアなコマンドラインインターフェースの構築において大きな可能性を秘めています。