https://github.com/siddhant-k-code/formal-ssh-proxy
An SSH proxy in Go. It sits between a client and an upstream server, logs every byte of stdin, and optionally asks an LLM to assess the session for security risk.
https://github.com/siddhant-k-code/formal-ssh-proxy
Last synced: 28 days ago
JSON representation
An SSH proxy in Go. It sits between a client and an upstream server, logs every byte of stdin, and optionally asks an LLM to assess the session for security risk.
- Host: GitHub
- URL: https://github.com/siddhant-k-code/formal-ssh-proxy
- Owner: Siddhant-K-code
- Created: 2026-04-13T05:42:43.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-04-13T12:52:51.000Z (3 months ago)
- Last Synced: 2026-05-30T02:26:55.521Z (29 days ago)
- Language: Go
- Homepage:
- Size: 30.3 KB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
Awesome Lists containing this project
README
# ssh-proxy
An SSH proxy in Go. It sits between a client and an upstream server, logs every byte of stdin, and optionally asks an LLM to assess the session for security risk.
## How it works
```
SSH client -> [proxy: auth + stdin capture + PTY resize] -> upstream sshd
```
The proxy handshakes with the client using its own host key and user list. It then opens a separate connection to the upstream. Stdin is tee'd to a log file before forwarding. Stdout and stderr flow back unchanged. Terminal resize requests are forwarded verbatim, so `vim` and `htop` behave normally.

## Quick start
`docker-compose.dev.yaml` ships with two test users. Run the setup script once after cloning to generate the example keys, then start the stack.
```bash
./scripts/setup.sh
docker compose -f docker-compose.dev.yaml up --build
```
| Container | localhost port | Description |
|------------|----------------|---------------------------|
| `proxy` | `2022` | The SSH proxy |
| `upstream` | `2222` | Alpine sshd (test target) |
Connect as alice (public key):
```bash
ssh -i example/keys/alice -o StrictHostKeyChecking=no -p 2022 alice@localhost
```
Connect as bob (password):
```bash
ssh -o StrictHostKeyChecking=no -p 2022 bob@localhost
# password: bobsecret
```
Connect directly to the upstream, bypassing the proxy:
```bash
ssh -o StrictHostKeyChecking=no -p 2222 proxyuser@localhost
# password: proxypassword
```
Session logs land in `./logs/` as `_.log`.
## Build locally
Requires Go 1.26+.
```bash
make build
./proxy -config example/config.yaml
```
| Target | What it does |
|---------------------|-------------------------------------|
| `make build` | Compile the binary |
| `make up` | `docker compose up --build -d` |
| `make down` | Stop containers |
| `make logs` | Tail proxy logs |
| `make test-connect` | Smoke-test with bob's password auth |
| `make clean` | Remove binary and session logs |
## Configuration
```bash
./proxy -config /path/to/config.yaml
```
See [`config.example.yaml`](config.example.yaml) for a fully annotated reference.
| Field | Description |
|-------|-------------|
| `listen` | Bind address (default `0.0.0.0:2022`) |
| `upstream.host` | Upstream hostname |
| `upstream.port` | Upstream port (default `22`) |
| `upstream.username` | Username for the upstream connection |
| `upstream.password` | Password for upstream auth; used when `private_key_path` is absent |
| `upstream.private_key_path` | PEM private key for upstream auth; takes precedence over password |
| `upstream.known_hosts_path` | Verify the upstream host key against this file. Skipped with a warning if absent. |
| `users[].username` | Client username |
| `users[].authorized_key_path` | Public key file for this user; preferred over password |
| `users[].password` | Password fallback when no key is configured or the key file is missing |
| `log_dir` | Where session logs are written |
| `host_key_path` | Persist the proxy host key here. A new key is generated each start if absent. |
| `llm.api_key` | OpenAI API key. Enables session summarization when set. |
| `llm.model` | Model name (default `gpt-4o-mini`) |
| `llm.base_url` | API base URL. Override for any OpenAI-compatible provider. |
### Auth order
For each user, the proxy tries:
1. **Public key** — if `authorized_key_path` is set and the client presents a matching key.
2. **Password** — fallback when no key is configured, or the key file is missing (logged as a warning).
A user with neither configured cannot connect.
### Volume mounts
```yaml
volumes:
- ./my-config.yaml:/etc/proxy/config.yaml:ro
- ./logs:/var/log/ssh-proxy
- ./host_key:/etc/proxy/host_key # optional: survive restarts without key warnings
```
## Session logs
```
logs/
alice_20240315T142301Z.log # raw stdin bytes
alice_20240315T142301Z.summary # LLM assessment (when enabled)
```
## LLM summarization
Set `llm.api_key` and the proxy will send each session transcript to the model after the client disconnects. It runs in a background goroutine so new connections are never blocked.
ANSI escape sequences and control characters are stripped before the transcript is sent. The model sees clean command text, not raw PTY bytes.
When the summary is ready:
```
[llm] security evaluation ready for session alice_20240315T142301Z.log (user: alice) -> logs/alice_20240315T142301Z.summary
```
Summary format:
```
RISK LEVEL: LOW|MEDIUM|HIGH|CRITICAL
SUMMARY:
SECURITY CONCERNS:
RECOMMENDATIONS:
```
Any OpenAI-compatible endpoint works. Set `llm.base_url` to point at Anthropic, Mistral, or a local Ollama instance.
## Design notes
**Stdin capture.** `io.TeeReader` writes each byte to the log before forwarding upstream. No extra copy, no buffering.
**Terminal resize.** `window-change` requests carry an 8-byte payload (columns, rows, pixel dimensions). The proxy forwards them verbatim. The upstream sshd delivers `SIGWINCH` to the shell.
**Shutdown ordering.** IO goroutines drain first. Then both channels close explicitly. Then the request forwarder goroutines exit. This order matters: closing the channels is what unblocks the forwarders. Waiting on the forwarders before closing would deadlock.
**Host key verification.** When `upstream.known_hosts_path` is set, the proxy verifies the upstream key with `golang.org/x/crypto/ssh/knownhosts`. Without it, verification is skipped with a warning. Fine for a controlled internal upstream; not for untrusted networks.
**Concurrency.** One goroutine per connection. Within a connection, IO and request forwarding run in separate goroutines behind `sync.WaitGroup`. The session logger holds a mutex so concurrent channels on the same connection write safely.