Garnish
A TUI framework for SSH-based applications. Based on Ratatouille's view architecture but adapted for Erlang's ssh framework. Provides secure remote access.
Garnish
Garnish is a TUI framework designed for applications accessed through SSH connections. Built on Ratatouille's view architecture and event processing system, it adapts to Erlang/OTP's SSH framework to enable development of secure remote terminal applications.
Features
SSH-Specialized Architecture
- SSH Integration: Deep integration with Erlang/OTP's SSH framework
- Remote Access: Execute TUI applications over network connections
- Session Management: Multi-user and multi-session support
- Authentication: Utilizes SSH standard authentication mechanisms
Ratatouille-Based Design
- View Architecture: Similar view construction system to Ratatouille
- Declarative UI: HTML-like DSL for user interface construction
- Event Processing: Unified handling of keyboard and mouse events
- Components: Reusable UI components
Security Features
- Encrypted Communication: Secure communication through SSH encryption
- Authentication Control: User authentication and access control
- Session Isolation: Guarantees independence of user sessions
- Audit Logging: Access logs and operation history recording
Basic Usage
Installation
# mix.exs
defp deps do
[
{:garnish, "~> 0.1.0"},
{:ssh, ">= 4.10.0"}
]
end
mix deps.get
Basic SSH TUI Server
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
# Start SSH server
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
File Manager TUI App
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
System Monitor TUI App
defmodule SystemMonitor do
use Garnish.SSHApp
import Garnish.View
def init(context) do
# Periodic system information updates
: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
# Get top system processes (example implementation)
[
%{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
# Helper functions for system information (example implementation)
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
Advanced Features
Multi-Session Management
defmodule MultiSessionApp do
use Garnish.SSHApp
import Garnish.View
def init(context) do
session_id = context.session_id
user = context.ssh_user
# Register session information in global state
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
# Broadcast message to all sessions
# Implementation depends on Garnish's internal session management
end
end
Ecosystem
Related Technologies
- Ratatouille: Base TUI framework
- Erlang SSH: Foundation SSH implementation
- OTP: Process management and supervisors
- GenServer: Session management and state
Security Considerations
- SSH Encryption: Encryption of all communications
- Authentication: Public key and password authentication
- Access Control: User permission management
- Session Isolation: Multi-tenant support
Advantages
- Remote Access: TUI applications over network connections
- Security: SSH standard security features
- Multi-User: Concurrent connections from multiple users
- Elixir Benefits: Concurrency and fault tolerance
- Ratatouille-Based: Rich UI components
Limitations
- New Project: Limited functionality due to early development stage
- Documentation: Limited documentation available
- Community: Small community size
- SSH Dependency: Requires SSH environment
- Network Constraints: Subject to latency effects
Comparison with Other Libraries
Feature | Garnish | Ratatouille | ElementTui |
---|---|---|---|
Connection | SSH | Local | Local |
Multi-User | ✓ | ✗ | ✗ |
Security | High | Low | Low |
Maturity | Low | High | Medium |
Use Case | Remote Management | Local TUI | General TUI |
Summary
Garnish is an innovative framework specialized for SSH-based TUI application development. Built on Ratatouille's solid architecture foundation, it provides secure remote access functionality through SSH connections. While still in early development, it holds great potential for building remote management tools, multi-user terminal applications, and secure command-line interfaces.