# SSH-Accessible Terminal Portfolio Guide

A practical guide to building a terminal-native portfolio site where `ssh yoursite.com` drops the visitor into a ratatui TUI that mirrors your website. Based on the implementation behind [agnelnieves.com](https://agnelnieves.com) (Rust + ratatui + russh + Fly.io), but the concepts apply to any stack.

---

## Use this guide with an AI agent

Paste this guide into your AI coding agent (Claude, Cursor, etc.) along with the prompt below. The prompt is the load-bearing piece; it tells the agent how to adapt the guide to your project and what to ask you before generating commands.

> I want to make my personal website accessible over SSH, like `ssh terminal.shop` or `ssh charm.sh`. Running `ssh mysite.com` should drop the visitor into a terminal UI rendering my site's content (bio, blog list, projects, etc.). Use the attached guide as your reference architecture. Adapt it to my stack: if my site isn't Next.js, tell me what to swap. Walk me through each step in order: (1) scaffolding the Rust CLI, (2) wiring it to my site's APIs for live data, (3) building the ratatui TUI, (4) adding the russh-based SSH server mode, (5) persisting the host key, (6) containerizing for Fly.io, (7) deploying, (8) pointing DNS. Stop and ask me for the details you need (domain, DNS provider, Fly region, package name, what content endpoints my site exposes) before generating commands. At the end, give me one command I can run to verify `ssh mysite.com` works.

---

## Table of Contents

- [Overview](#overview)
- [Prerequisites](#prerequisites)
- [1. Scaffold the Rust CLI](#1-scaffold-the-rust-cli)
- [2. Wire to your site's APIs](#2-wire-to-your-sites-apis)
- [3. Build the ratatui TUI](#3-build-the-ratatui-tui)
- [4. Add the russh SSH server mode](#4-add-the-russh-ssh-server-mode)
- [5. Parse input bytes into key events](#5-parse-input-bytes-into-key-events)
- [6. Persist the SSH host key](#6-persist-the-ssh-host-key)
- [7. Dockerfile](#7-dockerfile)
- [8. Fly.io configuration](#8-flyio-configuration)
- [9. Deploy](#9-deploy)
- [10. Point DNS](#10-point-dns)
- [Complete Checklist](#complete-checklist)
- [Verification](#verification)
- [Resources](#resources)

---

## Overview

Two design decisions drive everything else:

1. **One binary, two modes.** The same Rust binary runs as either a local CLI (`agnel`) or an SSH server (`agnel --serve`). Both modes share the rendering code, so adding a feature appears in both at once.
2. **No PTY fork.** Most "TUI over SSH" tutorials spawn a pseudo-terminal and re-exec the binary inside it. We skip that entirely with a custom `std::io::Write` backend that funnels bytes through the SSH channel directly. Lower memory, simpler control flow, easier deploy.

```
ssh yoursite.com
        │
        │   DNS A/AAAA → Fly anycast edge → port 22
        ▼
   Fly machine
   /usr/local/bin/agnel --serve --port 2222 \
                        --bind 0.0.0.0 \
                        --host-key /data/ssh_host_key
        │
        │   russh per-session task: ratatui renders into a custom
        │   Write backend that emits bytes back through the channel.
        ▼
   Live fetches from yoursite.com APIs (with local TTL cache).
```

---

## Prerequisites

- **Rust** 1.78+ (`rustup install stable`)
- **A domain** you control (we'll point DNS at the Fly IPs)
- **A Fly.io account** with a credit card on file (trial orgs cannot allocate dedicated IPv4, which raw TCP SSH needs)
- **`flyctl`** (`brew install flyctl` or `curl -L https://fly.io/install.sh | sh`)
- **A way to manage DNS** for your domain (Vercel Domains, Cloudflare, Namecheap, etc.)
- **Your website's content endpoints.** The CLI will fetch live data from them. Anything that returns JSON or markdown works.

---

## 1. Scaffold the Rust CLI

Inside your existing site repo, create a `cli/` directory and a minimal Cargo project:

```toml
# cli/Cargo.toml
[package]
name = "yourname"
version = "0.1.0"
edition = "2021"

[dependencies]
ratatui = "0.29"
crossterm = "0.28"
ureq = { version = "2", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
clap = { version = "4", features = ["derive"] }
russh = "0.60"
tokio = { version = "1", features = ["full"] }
anyhow = "1"
getrandom = "0.3"

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"
```

The release profile is tuned for small binary size, about 3 to 4 MB after `cargo build --release`.

Suggested layout:

```
cli/
├── Cargo.toml
├── Dockerfile
├── fly.toml
└── src/
    ├── main.rs       Entry, clap args, dispatch
    ├── render.rs     Shared frame composition
    ├── serve.rs      russh SSH server
    ├── app.rs        State machine, key handling
    ├── types.rs      Shared types (serde)
    ├── markdown.rs   Markdown → ratatui Line/Span
    ├── api/          HTTP client + per-endpoint modules
    └── ui/           Per-screen render functions
```

---

## 2. Wire to your site's APIs

Don't bundle content. Fetch it live from your site so a new blog post or project shows up in the terminal without redeploying the CLI.

```rust
// cli/src/api/client.rs
use std::env;

const DEFAULT_BASE_URL: &str = "https://yoursite.com";

fn base_url() -> String {
    env::var("YOURNAME_API_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
}

pub fn fetch_json<T: serde::de::DeserializeOwned>(path: &str) -> Result<T, String> {
    let url = format!("{}{}", base_url(), path);
    ureq::get(&url)
        .set("User-Agent", "yourname-cli/0.1.0")
        .call()
        .map_err(|e| format!("HTTP request failed: {e}"))?
        .into_json::<T>()
        .map_err(|e| format!("Failed to parse JSON: {e}"))
}
```

### Endpoints to expose on your site

If you're on Next.js, you likely already have some of these:

| Endpoint | Returns | Used for |
|---|---|---|
| `/feed.json` | JSON Feed 1.1 of blog posts | Blog list screen |
| `/api/blog/[slug]/raw` | Raw markdown | Blog post reader |
| `/api/projects` | JSON array of projects | Work / projects screen |
| `/api/site.json` | Bio, social links | Home + About + Connect |

If your stack is different, the principle holds: expose what you need as JSON or raw markdown, and the CLI fetches it. Cache locally:

```rust
// cli/src/api/cache.rs
// TTL file cache at ~/.yourname/cache/<key>
// 1 hour default, fall back to stale on fetch failure.
```

---

## 3. Build the ratatui TUI

Keep the render code in its own module so the local TUI and the SSH server share it:

```rust
// cli/src/render.rs
use ratatui::{Frame, layout::{Constraint, Layout, Rect}};
use crate::app::App;

pub fn render(frame: &mut Frame, app: &mut App) {
    let layout = Layout::vertical([
        Constraint::Length(2), // header
        Constraint::Min(0),    // content
        Constraint::Length(2), // footer
    ])
    .split(frame.area());

    render_header(frame, app, layout[0]);
    render_content(frame, app, layout[1]);
    render_footer(frame, app, layout[2]);
}
```

State machine pattern that holds well for portfolio-style apps:

```rust
// cli/src/app.rs (sketch)
pub enum Screen {
    Home, Blog, BlogPost { slug: String }, Projects, About, Connect,
}

pub struct App {
    pub screen: Screen,
    pub history: Vec<Screen>,
    pub running: bool,
    pub site_info: LoadState<SiteInfo>,
    pub feed: LoadState<JsonFeed>,
    pub projects: LoadState<ProjectsResponse>,
    // ...
    tx: mpsc::Sender<Message>,
    rx: mpsc::Receiver<Message>,
}

impl App {
    pub fn handle_key(&mut self, key: KeyEvent) {
        // q quits, h/a/b/c navigate, j/k/Up/Down scroll, Enter opens, Esc back
    }
}
```

Async fetches run on OS threads and send results through an `mpsc::Sender<Message>`. The render loop drains messages each tick:

```rust
fn run_tui(...) -> Result<(), Box<dyn std::error::Error>> {
    let mut terminal = ratatui::init();
    let mut app = App::new();
    app.fetch_initial_data();
    loop {
        terminal.draw(|frame| render::render(frame, &mut app))?;
        if crossterm::event::poll(Duration::from_millis(50))? {
            if let Event::Key(key) = crossterm::event::read()? {
                if key.kind == KeyEventKind::Press { app.handle_key(key); }
            }
        }
        app.process_messages();
        app.tick();
        if !app.running { break; }
    }
    ratatui::restore();
    Ok(())
}
```

---

## 4. Add the russh SSH server mode

This is the part most guides over-complicate. You don't need a PTY. Use a custom `std::io::Write` that forwards bytes through the SSH channel:

```rust
// cli/src/serve.rs
use russh::server::*;
use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};

struct TerminalSink {
    sender: UnboundedSender<Vec<u8>>,
    sink: Vec<u8>,
}

impl TerminalSink {
    async fn start(handle: Handle, channel_id: ChannelId) -> Self {
        let (sender, mut receiver) = unbounded_channel::<Vec<u8>>();
        tokio::spawn(async move {
            while let Some(data) = receiver.recv().await {
                if handle.data(channel_id, data).await.is_err() { break; }
            }
        });
        Self { sender, sink: Vec::new() }
    }
}

impl std::io::Write for TerminalSink {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        self.sink.extend_from_slice(buf);
        Ok(buf.len())
    }
    fn flush(&mut self) -> std::io::Result<()> {
        if !self.sink.is_empty() {
            let _ = self.sender.send(std::mem::take(&mut self.sink));
        }
        Ok(())
    }
}
```

Wire it into russh's `Handler`. Each session gets its own `App` and a `Terminal<CrosstermBackend<TerminalSink>>`:

```rust
impl Handler for AppServer {
    type Error = anyhow::Error;

    async fn auth_none(&mut self, _: &str) -> Result<Auth, Self::Error> {
        Ok(Auth::Accept)  // accept any username with no auth (same UX as terminal.shop)
    }

    async fn channel_open_session(
        &mut self,
        channel: Channel<Msg>,
        session: &mut Session,
    ) -> Result<bool, Self::Error> {
        let sink = TerminalSink::start(session.handle(), channel.id()).await;
        let terminal = Terminal::with_options(
            CrosstermBackend::new(sink),
            TerminalOptions { viewport: Viewport::Fixed(Rect::default()) },
        )?;
        let app = App::new();
        self.sessions.lock().await.insert(
            self.id,
            Arc::new(Mutex::new(SessionState { terminal, app, render_started: false })),
        );
        Ok(true)
    }

    async fn pty_request(/* ... col_width, row_height ... */) -> Result<(), Self::Error> {
        // resize terminal, trigger initial fetch
    }

    async fn shell_request(&mut self, channel: ChannelId, session: &mut Session)
        -> Result<(), Self::Error>
    {
        // spawn the per-session render loop in a tokio task
        session.channel_success(channel)?;
        Ok(())
    }

    async fn data(&mut self, _channel: ChannelId, data: &[u8], _session: &mut Session)
        -> Result<(), Self::Error>
    {
        for key in parse_keys(data) {
            self.session_state().lock().await.app.handle_key(key);
        }
        Ok(())
    }

    async fn window_change_request(/* ... */) -> Result<(), Self::Error> {
        // resize the per-session terminal
    }
}
```

Per-session render loop, ticked at 50ms:

```rust
async fn run_render_loop(
    state: Arc<Mutex<SessionState>>,
    handle: russh::server::Handle,
    channel: ChannelId,
    sessions: SessionMap,
    id: usize,
) {
    let mut interval = tokio::time::interval(Duration::from_millis(50));
    loop {
        interval.tick().await;
        if !sessions.lock().await.contains_key(&id) { break; }
        let still_running = {
            let mut g = state.lock().await;
            g.app.process_messages();
            g.app.tick();
            let s = &mut *g;
            let _ = s.terminal.draw(|frame| render::render(frame, &mut s.app));
            s.app.running
        };
        if !still_running {
            sessions.lock().await.remove(&id);
            let _ = handle.close(channel).await;
            break;
        }
    }
}
```

---

## 5. Parse input bytes into key events

Russh hands you raw bytes. Convert them into the same `crossterm::event::KeyEvent` your local TUI already handles. That way `App::handle_key` doesn't need to know it's running over SSH:

```rust
fn parse_keys(buf: &[u8]) -> Vec<KeyEvent> {
    let mut out = Vec::new();
    let mut i = 0;
    while i < buf.len() {
        let b = buf[i];
        match b {
            0x03 => { out.push(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); i += 1; }
            0x04 => { out.push(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)); i += 1; }
            0x1b => {
                if i + 2 < buf.len() && buf[i + 1] == b'[' {
                    let (code, adv) = match buf[i + 2] {
                        b'A' => (KeyCode::Up, 3),
                        b'B' => (KeyCode::Down, 3),
                        b'C' => (KeyCode::Right, 3),
                        b'D' => (KeyCode::Left, 3),
                        _ => (KeyCode::Esc, 1),
                    };
                    out.push(KeyEvent::new(code, KeyModifiers::NONE));
                    i += adv;
                } else {
                    out.push(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
                    i += 1;
                }
            }
            b'\r' | b'\n' => { out.push(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); i += 1; }
            0x20..=0x7e   => { out.push(KeyEvent::new(KeyCode::Char(b as char), KeyModifiers::NONE)); i += 1; }
            _ => { i += 1; }
        }
    }
    out
}
```

That covers Ctrl+C, Ctrl+D, Esc, arrow keys, Enter, and all printable ASCII. Extend for PageUp/PageDown/Home/End if you need them.

---

## 6. Persist the SSH host key

If the container generates a fresh Ed25519 key on every boot, every returning visitor gets `REMOTE HOST IDENTIFICATION HAS CHANGED` warnings after the next deploy. Persist the key to a volume:

```rust
fn load_or_create_host_key(path: &Path) -> Result<PrivateKey> {
    if path.exists() {
        let pem = std::fs::read_to_string(path)?;
        Ok(PrivateKey::from_openssh(pem)?)
    } else {
        let mut rng = SystemRng;
        let key = PrivateKey::random(&mut rng, Algorithm::Ed25519)?;
        let pem = key.to_openssh(LineEnding::LF)?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(path, pem.as_bytes())?;
        Ok(key)
    }
}
```

ssh-key 0.6 (russh's internal dep) uses rand_core 0.10, which dropped its built-in `OsRng`. A tiny wrapper around `getrandom::fill` satisfies the trait:

```rust
struct SystemRng;

impl russh::keys::ssh_key::rand_core::TryRng for SystemRng {
    type Error = core::convert::Infallible;
    fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
        let mut b = [0u8; 4]; getrandom::fill(&mut b).unwrap();
        Ok(u32::from_le_bytes(b))
    }
    fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
        let mut b = [0u8; 8]; getrandom::fill(&mut b).unwrap();
        Ok(u64::from_le_bytes(b))
    }
    fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Self::Error> {
        getrandom::fill(dst).unwrap(); Ok(())
    }
}
impl russh::keys::ssh_key::rand_core::TryCryptoRng for SystemRng {}
```

Wire the path via a CLI flag so production and local dev use different keys:

```rust
#[derive(Parser)]
struct Args {
    #[arg(long)] serve: bool,
    #[arg(long, default_value_t = 2222)] port: u16,
    #[arg(long, default_value = "0.0.0.0")] bind: String,
    #[arg(long, default_value = "ssh_host_key")] host_key: PathBuf,
}
```

---

## 7. Dockerfile

Multi-stage. Builder needs `cmake` and `clang` because aws-lc-rs (a russh transitive dep) compiles C. Runtime only needs `ca-certificates` for HTTPS to your site's APIs:

```dockerfile
# syntax=docker/dockerfile:1.6

FROM rust:1.94-slim-bookworm AS builder
WORKDIR /app
RUN apt-get update \
  && apt-get install -y --no-install-recommends \
       pkg-config libssl-dev cmake clang \
  && rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/app/target \
    cargo build --release --locked \
    && cp /app/target/release/yourname /yourname

FROM debian:bookworm-slim AS runtime
RUN apt-get update \
  && apt-get install -y --no-install-recommends ca-certificates \
  && rm -rf /var/lib/apt/lists/* \
  && groupadd -r yourname && useradd -r -g yourname yourname \
  && mkdir -p /data && chown yourname:yourname /data
COPY --from=builder /yourname /usr/local/bin/yourname
USER yourname
EXPOSE 2222
CMD ["/usr/local/bin/yourname", "--serve", "--port", "2222", \
     "--bind", "0.0.0.0", "--host-key", "/data/ssh_host_key"]
```

`.dockerignore`:

```
target/
*.save
.env
ssh_host_key*
.git
```

---

## 8. Fly.io configuration

```toml
# cli/fly.toml
app = "yourname-sh"
primary_region = "iad"  # pick a region near you

[build]
  dockerfile = "Dockerfile"

[[mounts]]
  source = "yourname_data"
  destination = "/data"

[[services]]
  protocol = "tcp"
  internal_port = 2222
  auto_stop_machines = "off"
  auto_start_machines = true
  min_machines_running = 1

  [[services.ports]]
    port = 22
    handlers = []

  [services.concurrency]
    type = "connections"
    soft_limit = 50
    hard_limit = 100

  [[services.tcp_checks]]
    interval = "30s"
    timeout = "5s"
    grace_period = "10s"

[[vm]]
  size = "shared-cpu-1x"
  memory = "256mb"
```

Notes:

- Map external port `22` to internal `2222`. Don't have your binary bind to `22` directly inside the container; Fly's edge handles the public port.
- `auto_stop_machines = "off"` keeps the server warm. SSH sessions are long-lived; cold starts mean failed handshakes.
- `tcp_checks` only verifies the TCP socket opens. That's enough; SSH banner exchange happens after.

---

## 9. Deploy

```bash
cd cli

# 1. Create the app
flyctl apps create yourname-sh --org personal

# 2. Create the persistent volume for the host key (1 GB is overkill; smallest available)
flyctl volumes create yourname_data \
    --app yourname-sh --size 1 --region iad --yes

# 3. Deploy
flyctl deploy --remote-only --yes
```

Confirm the binary is listening from inside the machine before exposing it:

```bash
flyctl ssh console --app yourname-sh \
    -C "bash -c 'exec 3<>/dev/tcp/127.0.0.1/2222; \
                  printf SSH-2.0-test\\r\\n >&3; head -c 50 <&3'"
# Expect: SSH-2.0-russh_0.60.2
```

### Allocate public IPs

Trial Fly orgs cannot allocate dedicated IPv4. Add a credit card before this step.

```bash
flyctl ips allocate-v6 --app yourname-sh    # free
flyctl ips allocate-v4 --app yourname-sh    # ~$2/mo (required for IPv4 SSH clients)
flyctl ips list --app yourname-sh           # capture both
```

Why both: residential ISPs in the US still mostly lack native IPv6 SSH paths, so IPv4 is functionally required. IPv6 is free and good to have.

---

## 10. Point DNS

Add A and AAAA records on the apex of your domain pointing at the Fly IPs from the previous step. The DNS commands vary by provider:

```bash
# Vercel Domains
vercel dns add yoursite.com @ A    <IPV4>
vercel dns add yoursite.com @ AAAA <IPV6>

# Cloudflare CLI
flarectl dns create --zone yoursite.com --name @ --type A    --content <IPV4>
flarectl dns create --zone yoursite.com --name @ --type AAAA --content <IPV6>

# Or do it in your registrar's dashboard.
```

DNS usually propagates within a minute or two. Verify before you celebrate:

```bash
dig +short A    yoursite.com @8.8.8.8
dig +short AAAA yoursite.com @8.8.8.8
```

If both return the Fly IPs, you're live.

---

## Complete Checklist

### Foundation

- [ ] Rust toolchain installed (`rustup --version`)
- [ ] `flyctl` installed and authenticated (`flyctl auth whoami`)
- [ ] Domain registered, DNS managed by a CLI-capable provider
- [ ] Credit card on file with Fly (required for IPv4)
- [ ] Site APIs returning JSON/markdown your CLI can consume

### CLI

- [ ] `cli/Cargo.toml` with russh + ratatui + tokio + anyhow + getrandom
- [ ] `src/render.rs` reused by both local TUI and SSH server
- [ ] `App` state machine isolated from rendering and I/O
- [ ] Per-endpoint API modules with TTL file cache
- [ ] Local TUI works (`cargo run`)

### SSH server

- [ ] `--serve --port --bind --host-key` flags wired
- [ ] `TerminalSink` custom Write backend
- [ ] `auth_none` / `auth_password` / `auth_publickey` all return `Auth::Accept`
- [ ] `pty_request` resizes terminal and triggers initial fetch
- [ ] `shell_request` starts the per-session render task
- [ ] `data` parses bytes into `KeyEvent` and forwards to `App::handle_key`
- [ ] `window_change_request` resizes the terminal
- [ ] `channel_close` / `channel_eof` remove the session from the map
- [ ] Host key persisted to a stable path

### Deploy

- [ ] `Dockerfile` (multi-stage, builder + slim runtime, ~30 MB image)
- [ ] `.dockerignore` excludes `target/`, `.git`, secrets
- [ ] `fly.toml` mounts a volume, maps external 22 → internal 2222
- [ ] App created (`flyctl apps create`)
- [ ] Volume created (`flyctl volumes create`)
- [ ] First deploy succeeds (`flyctl deploy --remote-only`)
- [ ] Public IPv4 + IPv6 allocated
- [ ] DNS A and AAAA records on the apex

---

## Verification

End-to-end test from any machine on the public internet:

```bash
# 1. SSH banner check (no full handshake, no auth)
python3 -c "
import socket
s = socket.create_connection(('yoursite.com', 22), timeout=10)
print(s.recv(50))
" 
# Expected: b'SSH-2.0-russh_0.60.2\r\n'

# 2. Full TUI session
ssh -o StrictHostKeyChecking=no yoursite.com
# You should see the home screen render immediately. Press q to quit.
```

If the banner check returns `Connection reset` or `Connection refused`:

- Run `flyctl status`. The machine may have auto-stopped if you're on a trial org or didn't set `auto_stop_machines = "off"`.
- Run `flyctl logs` and look for the `<yourname> sshd listening on 0.0.0.0:2222` line and any panic traces.
- From inside the machine (`flyctl ssh console`), do the `bash /dev/tcp/127.0.0.1/2222` test above. If that works but external doesn't, the issue is DNS or Fly's port mapping.

---

## Resources

- [ratatui docs](https://ratatui.rs), the TUI framework
- [russh crate](https://docs.rs/russh), the Rust SSH server we use
- [russh ratatui example](https://github.com/Eugeny/russh/blob/main/russh/examples/ratatui_app.rs), the reference architecture for the in-process custom backend
- [terminal.shop](https://terminal.shop), the inspiration
- [Charm's wish](https://charm.sh/blog/wish/), Go equivalent if you'd rather not write Rust
- [Fly.io app docs](https://fly.io/docs/apps/), `fly.toml` reference
- [ANSI Shadow font on patorjk.com](https://patorjk.com/software/taag/#p=display&f=ANSI%20Shadow), for the home banner
