{"id":50527225,"url":"https://github.com/zachcheung/ssh-gateway","last_synced_at":"2026-06-08T22:00:30.046Z","repository":{"id":349873912,"uuid":"1193432850","full_name":"zachcheung/ssh-gateway","owner":"zachcheung","description":"Poor man's SSH gateway for multiple projects","archived":false,"fork":false,"pushed_at":"2026-06-03T09:14:42.000Z","size":85,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-03T09:26:43.177Z","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":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/zachcheung.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","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-03-27T08:08:08.000Z","updated_at":"2026-06-03T09:14:45.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/zachcheung/ssh-gateway","commit_stats":null,"previous_names":["zachcheung/ssh-gateway"],"tags_count":8,"template":false,"template_full_name":null,"purl":"pkg:github/zachcheung/ssh-gateway","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zachcheung%2Fssh-gateway","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zachcheung%2Fssh-gateway/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zachcheung%2Fssh-gateway/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zachcheung%2Fssh-gateway/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/zachcheung","download_url":"https://codeload.github.com/zachcheung/ssh-gateway/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zachcheung%2Fssh-gateway/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34082130,"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-06-08T02:00:07.615Z","response_time":111,"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-06-03T09:00:47.356Z","updated_at":"2026-06-08T22:00:30.041Z","avatar_url":"https://github.com/zachcheung.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# SSH Gateway\n\nPoor man's SSH gateway for multiple projects.\n\n```mermaid\nflowchart LR\n  client --\u003e gw_alpha\n  client --\u003e gw_beta\n  subgraph host [Gateway Host]\n    gw_alpha[\"ssh-gw-alpha :2201\"]\n    gw_beta[\"ssh-gw-beta :2202\"]\n  end\n  gw_alpha --\u003e alpha1\n  gw_alpha --\u003e alpha2\n  gw_beta --\u003e beta1\n```\n\n## Config\n\n```yaml\nproject: alpha\nkey_provider: github\nkey_types:\n  allowed: [ed25519]\nreconcile_interval: 15m\nusers:\n  - name: alice\n    keys:\n      - ssh-ed25519 AAAA... alice@laptop\n  - name: bob\n```\n\n| Field                  | Description |\n|------------------------|-------------|\n| `project`              | Project name (required) |\n| `key_provider`         | Default key source for users without explicit `keys`. Shorthands: `github`, `gitlab`, or a full URL base (e.g. `https://keys.example.com`). Keys are fetched from `\u003cbase\u003e/\u003cusername\u003e.keys`. |\n| `key_types`            | Filter keys by type (`ecdsa`, `ecdsa-sk`, `ed25519`, `ed25519-sk`, `rsa`). Use `allowed` or `disallowed`; if both are set, `allowed` wins. |\n| `reconcile_interval`   | Periodically re-fetch keys from `key_provider` or URL keys, without any config file change. Useful when team members rotate their GitHub/GitLab keys. Minimum `5s`. Omit to disable. |\n| `fetch_keys_on_reload` | Re-fetch provider and URL keys on config reload (fsnotify / SIGHUP), in addition to the periodic interval. Default `false`: reloads only apply structural changes (users added/removed, inline keys updated) while leaving existing fetched keys untouched. Set to `true` to force an immediate key refresh on every reload. |\n| `keep_sshd_config`     | If `true` and `/etc/ssh/sshd_config` already exists, leave it untouched on startup — the operator owns that file. Default `false`: the file is replaced when it drifts from the built-in (with a warning). Useful when custom sshd tunables (e.g. connection limits, timeouts, banner) are needed. |\n| `log_endpoint`         | Forward structured JSON logs to an external processor (`tcp://`, `udp://`, `http://`, `https://`). When set, reconcile events are emitted as JSON with `event_id`, `phase` (`start`/`end`), `trigger`, and per-change records. See [Change Notifications](#change-notifications). |\n\n## Run\n\n```yaml\n# compose.yml\nservices:\n  ssh-gateway:\n    image: ghcr.io/zachcheung/ssh-gateway\n    restart: unless-stopped\n    ports:\n      - '2201:22'\n    volumes:\n      - .:/etc/ssh-gateway:ro\n```\n\n```sh\ndocker compose up -d\n```\n\nSee [examples/](examples/) for multi-project and bind mount setups.\n\n\u003e **Note:** Mount the config *directory*, not the individual file. Docker binds single-file mounts to the original inode, so editors that write atomically (rename into place) leave the container reading the old content and fsnotify never fires.\n\n### Config file locations\n\nThe binary searches for the config file in the following order, stopping at the first match:\n\n| Path | Example mount |\n|------|---------------|\n| `/etc/ssh-gateway/config.yaml` | `- .:/etc/ssh-gateway:ro` |\n| `/etc/ssh-gateway/config.yml` | `- .:/etc/ssh-gateway:ro` |\n| `/etc/ssh-gateway/config/config.yaml` | `- ./config:/etc/ssh-gateway/config:ro` |\n| `/etc/ssh-gateway/config/config.yml` | `- ./config:/etc/ssh-gateway/config:ro` |\n\nThe `config/` subdirectory layout lets you mount only the config directory rather than the entire `/etc/ssh-gateway` tree, which is useful when host keys or other generated files share the parent directory.\n\n### Migrating existing host keys\n\nHost keys are generated once on first start and reused on subsequent starts via the `/etc/ssh` volume. To migrate keys from an existing server, place them in the volume directory **before** starting the container — `GenerateHostKeys` skips generation if the files already exist:\n\n```sh\ncp /old-server/etc/ssh/ssh_host_*_key* ./ssh/\ndocker compose up -d\n```\n\nIf the container is already running with auto-generated keys, copy the files then restart:\n\n```sh\ncp /old-server/etc/ssh/ssh_host_*_key* ./ssh/\ndocker compose restart ssh-gateway\n```\n\nRestarting is required because sshd loads host keys at startup and does not reload them at runtime.\n\n## Change Notifications\n\nSet `log_endpoint` to forward structured JSON logs to an external processor that can trigger notifications (email, Slack, PagerDuty, etc.):\n\n```yaml\nlog_endpoint: tcp://log-processor:5170\n```\n\nEach reconcile cycle emits a `phase: start` record and a `phase: end` record sharing the same `event_id`. Change records (user added/removed, key added/removed) appear between them. The `changes` field on the end record is `0` for no-op reconciles, letting the processor suppress notifications when nothing changed.\n\nSupported schemes: `tcp`, `udp`, `http`, `https`. Logs are forwarded asynchronously — a slow or unavailable endpoint does not block reconcile.\n\n## Reload\n\nConfig changes are detected automatically via filesystem watch — no manual step needed when the config file is bind-mounted from the host or updated via Docker volumes.\n\nTo trigger a reload manually (e.g. from a script):\n\n```sh\ndocker compose kill -s HUP ssh-gateway\n```\n\n## SSH\n\nConnect through the gateway as a jump host:\n\n```sh\nssh -J alice@gateway:2201 alice@alpha1\n```\n\nOr in `~/.ssh/config`:\n\n```\nHost gw-alpha\n  HostName gateway\n  Port 2201\n  User alice\n\nHost alpha1\n  ProxyJump gw-alpha\n  User alice\n```\n\n## How It Works\n\nA single host serves as SSH jump gateway for multiple projects, each with isolated users. One container per project, managed via YAML config.\n\nThe Go binary manages sshd and system users inside the container. It reconciles users and their `authorized_keys` on three triggers: startup, config file change (via inotify watch on the config directory), and `SIGHUP`. If `reconcile_interval` is set, keys are also re-fetched on a timer to pick up external key rotations without any config change. No shell access is granted (`ForceCommand /bin/false`). Host keys (`/etc/ssh`), home directories (`/home`), and the user database (`/var/lib/ssh-gateway-users`, where `/etc/passwd`, `/etc/shadow`, `/etc/group` symlink into) are persisted via Docker volumes so container recreation does not re-add existing users.\n\nEach `authorized_keys` file is managed exclusively by ssh-gateway and annotated with source markers (ignored by sshd) that track where each key came from. On a config reload, only inline keys are updated from the config; provider and URL keys are preserved from the previous fetch. This means a compromised key provider cannot push new keys via a config reload — keys from external sources only change when the periodic timer fires (or when `fetch_keys_on_reload: true` is set). A container restart behaves the same as a config reload; only a missing `authorized_keys` file (first-ever start) triggers a fetch unconditionally. Sources removed from the config are evicted immediately on any reload.\n\n## Development\n\nRun the integration tests:\n\n```sh\n# --abort-on-container-exit stops all services when the test container exits\ndocker compose -f compose.test.yml up --build --abort-on-container-exit\n```\n\n## License\n\n[MIT](LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fzachcheung%2Fssh-gateway","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fzachcheung%2Fssh-gateway","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fzachcheung%2Fssh-gateway/lists"}