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.

TUITerminalElixirSSHRemote-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

FeatureGarnishRatatouilleElementTui
ConnectionSSHLocalLocal
Multi-User
SecurityHighLowLow
MaturityLowHighMedium
Use CaseRemote ManagementLocal TUIGeneral 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.