{"id":50411812,"url":"https://github.com/siddhant-k-code/formal-ssh-proxy","last_synced_at":"2026-05-31T04:02:34.429Z","repository":{"id":361299560,"uuid":"1209121400","full_name":"Siddhant-K-code/formal-ssh-proxy","owner":"Siddhant-K-code","description":"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.","archived":false,"fork":false,"pushed_at":"2026-04-13T12:52:51.000Z","size":31,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-30T02:26:55.521Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/Siddhant-K-code.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-13T05:42:43.000Z","updated_at":"2026-05-07T13:36:31.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/Siddhant-K-code/formal-ssh-proxy","commit_stats":null,"previous_names":["siddhant-k-code/formal-ssh-proxy"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/Siddhant-K-code/formal-ssh-proxy","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Siddhant-K-code%2Fformal-ssh-proxy","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Siddhant-K-code%2Fformal-ssh-proxy/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Siddhant-K-code%2Fformal-ssh-proxy/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Siddhant-K-code%2Fformal-ssh-proxy/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/Siddhant-K-code","download_url":"https://codeload.github.com/Siddhant-K-code/formal-ssh-proxy/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Siddhant-K-code%2Fformal-ssh-proxy/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33718446,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-31T02:00:06.040Z","response_time":95,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-05-31T04:02:33.004Z","updated_at":"2026-05-31T04:02:34.415Z","avatar_url":"https://github.com/Siddhant-K-code.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# ssh-proxy\n\nAn 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.\n\n## How it works\n\n```\nSSH client -\u003e [proxy: auth + stdin capture + PTY resize] -\u003e upstream sshd\n```\n\nThe 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.\n\n![demo](assets/demo.svg)\n\n## Quick start\n\n`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.\n\n```bash\n./scripts/setup.sh\ndocker compose -f docker-compose.dev.yaml up --build\n```\n\n| Container  | localhost port | Description               |\n|------------|----------------|---------------------------|\n| `proxy`    | `2022`         | The SSH proxy             |\n| `upstream` | `2222`         | Alpine sshd (test target) |\n\nConnect as alice (public key):\n```bash\nssh -i example/keys/alice -o StrictHostKeyChecking=no -p 2022 alice@localhost\n```\n\nConnect as bob (password):\n```bash\nssh -o StrictHostKeyChecking=no -p 2022 bob@localhost\n# password: bobsecret\n```\n\nConnect directly to the upstream, bypassing the proxy:\n```bash\nssh -o StrictHostKeyChecking=no -p 2222 proxyuser@localhost\n# password: proxypassword\n```\n\nSession logs land in `./logs/` as `\u003cusername\u003e_\u003ctimestamp\u003e.log`.\n\n## Build locally\n\nRequires Go 1.26+.\n\n```bash\nmake build\n./proxy -config example/config.yaml\n```\n\n| Target              | What it does                        |\n|---------------------|-------------------------------------|\n| `make build`        | Compile the binary                  |\n| `make up`           | `docker compose up --build -d`      |\n| `make down`         | Stop containers                     |\n| `make logs`         | Tail proxy logs                     |\n| `make test-connect` | Smoke-test with bob's password auth |\n| `make clean`        | Remove binary and session logs      |\n\n## Configuration\n\n```bash\n./proxy -config /path/to/config.yaml\n```\n\nSee [`config.example.yaml`](config.example.yaml) for a fully annotated reference.\n\n| Field | Description |\n|-------|-------------|\n| `listen` | Bind address (default `0.0.0.0:2022`) |\n| `upstream.host` | Upstream hostname |\n| `upstream.port` | Upstream port (default `22`) |\n| `upstream.username` | Username for the upstream connection |\n| `upstream.password` | Password for upstream auth; used when `private_key_path` is absent |\n| `upstream.private_key_path` | PEM private key for upstream auth; takes precedence over password |\n| `upstream.known_hosts_path` | Verify the upstream host key against this file. Skipped with a warning if absent. |\n| `users[].username` | Client username |\n| `users[].authorized_key_path` | Public key file for this user; preferred over password |\n| `users[].password` | Password fallback when no key is configured or the key file is missing |\n| `log_dir` | Where session logs are written |\n| `host_key_path` | Persist the proxy host key here. A new key is generated each start if absent. |\n| `llm.api_key` | OpenAI API key. Enables session summarization when set. |\n| `llm.model` | Model name (default `gpt-4o-mini`) |\n| `llm.base_url` | API base URL. Override for any OpenAI-compatible provider. |\n\n### Auth order\n\nFor each user, the proxy tries:\n\n1. **Public key** — if `authorized_key_path` is set and the client presents a matching key.\n2. **Password** — fallback when no key is configured, or the key file is missing (logged as a warning).\n\nA user with neither configured cannot connect.\n\n### Volume mounts\n\n```yaml\nvolumes:\n  - ./my-config.yaml:/etc/proxy/config.yaml:ro\n  - ./logs:/var/log/ssh-proxy\n  - ./host_key:/etc/proxy/host_key   # optional: survive restarts without key warnings\n```\n\n## Session logs\n\n```\nlogs/\n  alice_20240315T142301Z.log      # raw stdin bytes\n  alice_20240315T142301Z.summary  # LLM assessment (when enabled)\n```\n\n## LLM summarization\n\nSet `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.\n\nANSI escape sequences and control characters are stripped before the transcript is sent. The model sees clean command text, not raw PTY bytes.\n\nWhen the summary is ready:\n```\n[llm] security evaluation ready for session alice_20240315T142301Z.log (user: alice) -\u003e logs/alice_20240315T142301Z.summary\n```\n\nSummary format:\n```\nRISK LEVEL: LOW|MEDIUM|HIGH|CRITICAL\n\nSUMMARY:\n\u003cwhat the user did\u003e\n\nSECURITY CONCERNS:\n\u003cspecific risks, or \"None identified\"\u003e\n\nRECOMMENDATIONS:\n\u003cmitigations, or \"None required\"\u003e\n```\n\nAny OpenAI-compatible endpoint works. Set `llm.base_url` to point at Anthropic, Mistral, or a local Ollama instance.\n\n## Design notes\n\n**Stdin capture.** `io.TeeReader` writes each byte to the log before forwarding upstream. No extra copy, no buffering.\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsiddhant-k-code%2Fformal-ssh-proxy","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsiddhant-k-code%2Fformal-ssh-proxy","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsiddhant-k-code%2Fformal-ssh-proxy/lists"}