Garnish
SSHベースアプリケーション用TUIフレームワーク。Ratatouilleのviewアーキテクチャをベースに、Erlangのsshフレームワークに適応。セキュアなリモートアクセスを提供。
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環境が必要
- ネットワーク制約: レイテンシーの影響
他のライブラリとの比較
項目 | Garnish | Ratatouille | ElementTui |
---|---|---|---|
接続方式 | SSH | ローカル | ローカル |
マルチユーザー | ○ | × | × |
セキュリティ | 高 | 低 | 低 |
成熟度 | 低 | 高 | 中 |
用途 | リモート管理 | ローカルTUI | 一般的TUI |
まとめ
GarnishはSSHベースのTUIアプリケーション開発に特化した革新的なフレームワークです。Ratatouilleの堅実なアーキテクチャを基盤として、SSH接続によるセキュアなリモートアクセス機能を提供します。開発初期段階のプロジェクトですが、リモート管理ツール、マルチユーザーターミナルアプリケーション、セキュアなコマンドラインインターフェースの構築において大きな可能性を秘めています。