Charon is a Go SSH proxy. It accepts client SSH connections, authenticates users, opens one configured upstream SSH connection, forwards channels/requests, records client stdin, extracts commands, stores session metadata in
SQLite, then optionally queues LLM analysis through a separate analyzer process.
The core architectural pitch: the SSH data path stays lean, and expensive/flaky model work is moved to a durable SQLite queue consumed by charon-analyzer.
Entrypoints
-
Proxy binary: cmd/charon/main.go:17
Loads config, opens SQLite store, builds proxy.Server, starts listening. -
Analyzer binary: cmd/charon-analyzer/main.go:30
Loads analyzer config, checks API key/analysis enabled, starts N workers. -
Config shape/defaults: internal/config/config.go:155
TOML config, strict unknown-key validation, separate proxy/analyzer validation.
Proxy Path
Main file: internal/proxy/server.go:30
Flow inside handleTCPConn: internal/proxy/server.go:100
- SSH handshake with client.
- Open per-session raw stdin log.
- Create session row in SQLite.
- Dial upstream SSH.
- Bridge global requests and channels both ways.
- On close, finalize session: parse commands, apply rules, persist facts, enqueue analysis.
Forwarding/channel logic lives in internal/proxy/bridge.go:91. It forwards session channels, exec/shell/PTY-ish request traffic, stderr, exit status, and optionally TCP/agent/X11 depending on config. Client-to-upstream stdin is
tee’d into the raw log.
Auth lives in internal/proxy/auth.go:28. Nice detail to mention: password auth always does one bcrypt comparison, using a dummy hash for unknown users, to avoid username timing leaks.
Session Logging And Command Extraction
- Opens session log dirs/files: internal/commandlog/session.go:23
- Extracts commands from raw terminal input: internal/commandlog/normalize.go:48
This is not a dumb line split. It replays terminal editing semantics: backspace, Ctrl-C, Ctrl-U, ANSI escape skipping, limits for hostile giant input, etc. That’s probably one of the more interesting implementation areas.
Artifacts are shaped like:
<log_dir>/<safe_username>/
stdin.commands.jsonl
stdin.llm.summary.json
stdin.requester.llm.summary.json
Finalize / Analysis Queue
Session finalization is here: internal/proxy/finalize.go:20
It:
- Reads stdin.raw
- Extracts commands
- Redacts sensitive values
- Applies static analyzer rules
- Writes stdin.commands.jsonl
- Persists commands/session risk to SQLite
- Enqueues an LLM analysis job if analysis is enabled
Queue code: internal/analyzer/jobs.go:18
It uses durable SQLite rows with statuses like queued/running/failed/dead/succeeded, leases, retry backoff, and max attempts.
Analyzer Worker
Worker loop: internal/analyzer/worker.go:14
Important method: internal/analyzer/worker.go:65
The worker claims a job, prepares model choice based on scope, calls an OpenAI-compatible API, saves results, maybe queues a cross-session requester investigation, then completes the job.
OpenAI-compatible client: internal/analyzer/openai_compatible.go:1
The code asks for JSON output and validates the model result shape before saving.
Correlation
Cross-session/requester correlation: internal/analyzer/correlation.go:21
It joins sessions by username or public key fingerprint within a lookback window. If a session’s risk meets the configured threshold and there are related summaries, it queues a requester investigation job.
Storage
SQLite store setup: internal/store/store.go:22
Generated query layer comes from sqlc. Queries are in internal/store/queries.sql:1. Migrations are embedded from internal/store/migrations.
A couple patterns worth saying out loud:
- modernc.org/sqlite, so pure Go SQLite.
- WAL mode for file-backed DBs.
- busy_timeout.
- SetMaxOpenConns(1), conservative because SQLite has one writer anyway.
Tests
Tests are mostly real-ish integration/unit paths, not mock soup:
- SSH auth/bridge/server tests under internal/proxy
- Command parser tests under internal/commandlog
- Queue/correlation/OpenAI-compatible tests under internal/analyzer
- Store migration/query tests under internal/store
Useful command:
go test ./…
README.md says the normal local build is:
go test ./…
go build ./cmd/charon
go build ./cmd/charon-analyzer
Good Interview Sound Bites
- “The proxy never calls the LLM provider directly. It only writes durable analysis jobs, so slow model calls cannot stall SSH sessions.”
- “The bridge uses x/crypto/ssh directly because the code needs raw channel/request behavior.”
- “Raw stdin is treated as hostile input. Command extraction streams, enforces caps, and models terminal editing instead of regexing the whole file.”
- “SQLite is both the session index and durable job queue. Analyzer workers are stateless consumers with leases and retry backoff.”
- “Correlation is intentionally identity-based across username and public key fingerprint, not just session-local.”