From ea6e7b7eb4949255f444c7e5b484f9437a68a00e Mon Sep 17 00:00:00 2001 From: Thomas <27960254+thomiceli@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:39:02 +0700 Subject: [PATCH] Add OpenSSH support (#735) Signed-off-by: Thomas Miceli --- config.yml | 26 ++- docs/.vitepress/config.mts | 1 + docs/administration/openssh.md | 142 ++++++++++++++++ docs/configuration/cheat-sheet.md | 104 ++++++------ internal/actions/actions.go | 13 ++ internal/cli/admin.go | 16 ++ internal/cli/hook.go | 28 +--- internal/cli/keys.go | 65 ++++++++ internal/cli/main.go | 2 +- internal/cli/shell.go | 92 +++++++++++ internal/cli/subprocess.go | 31 ++++ internal/config/config.go | 64 +++++++- internal/db/gist.go | 12 +- internal/db/sshkey.go | 18 +++ internal/hooks/post_receive.go | 96 +++++++---- internal/hooks/pre_receive.go | 65 ++++++-- internal/hooks/pre_receive_test.go | 54 ------- internal/i18n/locales/en-US.yml | 2 + internal/ipc/hooks.go | 59 +++++++ internal/ipc/ipc.go | 101 ++++++++++++ internal/ipc/keys.go | 25 +++ internal/ipc/shell.go | 31 ++++ internal/ssh/authorized_keys.go | 151 ++++++++++++++++++ internal/ssh/git_ssh.go | 40 +++-- internal/ssh/run.go | 4 +- internal/web/handlers/admin/actions.go | 6 + internal/web/handlers/admin/admin.go | 4 + .../web/handlers/api/v1/gist_list_test.go | 7 +- internal/web/handlers/api/v1/gist_test.go | 2 +- internal/web/handlers/ipc/hook.go | 53 ++++++ internal/web/handlers/ipc/ssh.go | 61 +++++++ internal/web/handlers/settings/account.go | 2 + internal/web/handlers/settings/sshkey.go | 3 + internal/web/server/middlewares.go | 18 ++- internal/web/server/router.go | 9 ++ templates/pages/admin_index.html | 8 + 36 files changed, 1202 insertions(+), 213 deletions(-) create mode 100644 docs/administration/openssh.md create mode 100644 internal/cli/keys.go create mode 100644 internal/cli/shell.go create mode 100644 internal/cli/subprocess.go delete mode 100644 internal/hooks/pre_receive_test.go create mode 100644 internal/ipc/hooks.go create mode 100644 internal/ipc/ipc.go create mode 100644 internal/ipc/keys.go create mode 100644 internal/ipc/shell.go create mode 100644 internal/ssh/authorized_keys.go create mode 100644 internal/web/handlers/ipc/hook.go create mode 100644 internal/web/handlers/ipc/ssh.go diff --git a/config.yml b/config.yml index 7b41d7d..d769a64 100644 --- a/config.yml +++ b/config.yml @@ -72,25 +72,35 @@ metrics.host: 0.0.0.0 metrics.port: 6158 # SSH built-in server configuration -# Note: it is not using the SSH daemon from your machine (yet) -# Enable or disable SSH built-in server -# for git operations (clone, pull, push) via SSH (either `true` or `false`). Default: true -ssh.git-enabled: true +# Enable or disable and specify which SSH server serves git over SSH (either `builtin`, `host`, or `disabled` (no SSH git access). +# Default: builtin +ssh.git-enabled: builtin # Host to bind to. Default: 0.0.0.0 +# This is for builtin SSH server only. ssh.host: 0.0.0.0 -# Port to bind to. Default: 2222 -# Note: it cannot be the same port as the SSH daemon if it's currently running -# If you want to use the port 22 for the built-in SSH server, -# you can either change the port of the SSH daemon or stop it +# Default: 2222 +# For the builtin server, this is the port it binds to. +# Note: it cannot be the same port as the SSH daemon if it's currently running. +# If you want to use port 22 for the built-in server, change the SSH daemon's port or stop it. +# For `host` mode, this is only used to build the SSH clone URLs (set it to your OpenSSH port, usually 22). ssh.port: 2222 +# Username shown in SSH clone URLs (e.g. `gist` → `gist@host:user/repo.git`). +# For `host` mode set it to the OS account clients log in as. If empty, URLs carry no username. +ssh.username: + # Public domain for the Git SSH connection, if it has to be different from the HTTP one. # If not set, uses the URL from the request ssh.external-domain: +# Path to an `authorized_keys` file Opengist keeps in sync with users' keys (e.g. `/home/gist/.ssh/authorized_keys`), +# used when `ssh.git-enabled` is `host`, +# Leave empty to use OpenSSH's `AuthorizedKeysCommand` instead. +ssh.authorized-keys-file: + # OAuth2 configuration # The callback/redirect URL must be http://opengist.url/oauth//callback diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 0418559..7fc2956 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -90,6 +90,7 @@ const docsSidebar = [ { text: 'Administration', base: '/docs/administration', items: [ {text: 'Run with systemd', link: '/run-with-systemd'}, + {text: 'Git over SSH with OpenSSH', link: '/openssh'}, {text: 'Reverse proxy', items: [ {text: 'Nginx', link: '/nginx-reverse-proxy'}, {text: 'Traefik', link: '/traefik-reverse-proxy'}, diff --git a/docs/administration/openssh.md b/docs/administration/openssh.md new file mode 100644 index 0000000..61d14c3 --- /dev/null +++ b/docs/administration/openssh.md @@ -0,0 +1,142 @@ +# Serve Git over SSH with OpenSSH + +Opengist can serve git over SSH (clone, pull, push) in two ways, selected with +the [`ssh.git-enabled`](/docs/configuration/cheat-sheet) config key: + +- **`builtin`** (default): Opengist runs its own embedded SSH server, by default + on port `2222`. Self-contained, works in Docker and rootless setups, and needs + no extra configuration. This is recommended for most installs. +- **`host`**: Opengist delegates SSH access to the machine's own OpenSSH server + (`sshd`), so clients connect on the standard port `22` through the same SSH + daemon that already runs on the host. Best for installs where you + want a single SSH entry point, that can be shared with other services like Gitea. +- **`disabled`**: no SSH git access at all. + +This page covers the **`host`** mode. + +## Requirements + +Host mode runs an Opengist subcommand from inside `sshd`, so a few things must be +true: + +- **`sshd`, Opengist and the gist repositories share the same machine and OS user.** + The connection authenticates as a single OS account (e.g. `opengist` or `git`), + and git runs directly against the repositories on disk. A split setup - + host `sshd` in front of a containerized Opengist - is **not** supported, because + the git data must be on the same filesystem. +- **Opengist is started with a config file via `--config`** (for example + `opengist --config /etc/opengist/config.yml`). Host mode resolves the daemon's + settings through that file when `sshd` invokes Opengist; a pure environment-only + configuration is not supported in this mode. +- A **dedicated OS user** for Opengist is strongly recommended. +- Users have added their SSH keys in Opengist as usual (**Settings → SSH keys**); + the keys come from Opengist's database. + +## How it works + +When a client connects, `sshd` has to (1) recognize the public key and (2) limit +what the session may do. Opengist plugs into both: + +- **Key lookup** - `sshd` asks Opengist whether the offered key belongs to an + Opengist user. +- **Forced command** - the matching `authorized_keys` entry pins the session to + `opengist shell `. The connection can do nothing but run a single git + command, which Opengist authorizes against the target gist before streaming it. + Shells, port forwarding, PTYs, etc. are all denied. + +There are two ways to wire this up. Pick **one**. + +## Option A - `AuthorizedKeysCommand` + +`sshd` calls Opengist on each connection to resolve the offered key. Nothing is +written to disk, and keys added or removed in the web UI take effect immediately. + +1. Configure Opengist (`/etc/opengist/config.yml`): + + ```yaml + ssh.git-enabled: host + ssh.external-domain: gist.example.com # host clients will SSH to + ssh.port: "22" + ssh.username: gist # the OS account clients log in as + ``` + +2. Configure `sshd` (`/etc/ssh/sshd_config`). Use a `Match` block so only the + Opengist account is affected: + + ``` + Match User gist + AuthorizedKeysCommand /usr/local/bin/opengist --config /path/to/config.yml keys -t %t -k %k + AuthorizedKeysCommandUser gist + ``` + + > `--config` is a global flag, so it must come **before** the `keys` + > subcommand. `%t` and `%k` are the key type and content `sshd` passes in. + +3. Reload `sshd`: + + ```shell + sudo systemctl reload ssh + ``` + +Notes: + +- `sshd` requires the `AuthorizedKeysCommand` binary (and every parent directory) + to be owned by `root` and not writable by group/others. A binary installed at + `/usr/local/bin/opengist` satisfies this. +- `AuthorizedKeysCommandUser` should be the Opengist OS user, so the command can + reach the running daemon and read its secret key. + +## Option B - Managed `authorized_keys` file + +Opengist maintains a managed block inside the OS user's `authorized_keys`. Use +this when you can't edit `sshd_config` (e.g. no `AuthorizedKeysCommand`), or +prefer a static file. + +1. Configure Opengist: + + ```yaml + ssh.git-enabled: host + ssh.authorized-keys-file: /home/gist/.ssh/authorized_keys + ssh.external-domain: gist.example.com + ssh.port: "22" + ssh.username: gist # the OS account clients log in as + ``` + +2. Restart Opengist. It (re)generates the managed block: + - whenever an SSH key or a user is added or removed, + - and every 72 hours, in case the file drifts. + +Opengist writes the file with the strict permissions `sshd` expects under +`StrictModes` (`.ssh` `0700`, `authorized_keys` `0600`). Only the block between +the markers is managed - any keys you add outside it are preserved: + +``` +# --- opengist managed keys start (do not edit) --- +command="/home/gist/.opengist/symlinks/opengist --config /home/gist/.opengist/symlinks/config.yml shell 1",no-port-forwarding,no-x11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA... thomas@laptop +# --- opengist managed keys end --- +``` + +## Cloning + +Clone URLs in the UI are built from `ssh.external-domain`, `ssh.port` and +`ssh.username`. Because OpenSSH authenticates by key rather than by Opengist +username, set `ssh.username` to the OS account clients log in as so the displayed +URL carries it: + +```shell +git clone gist@gist.example.com:thomas/mygist.git +``` + +If you leave `ssh.username` empty the URL has no account name, and clients fall +back to their local username - usually not what you want in `host` mode. Either +set `ssh.username`, or have each client set it once in `~/.ssh/config`: + +``` +Host gist.example.com + User gist +``` + +## Disabling SSH + +Set `ssh.git-enabled: disabled` to turn off SSH git access entirely: the embedded +server won't start and SSH clone URLs are hidden in the UI. diff --git a/docs/configuration/cheat-sheet.md b/docs/configuration/cheat-sheet.md index db53211..5e802d8 100644 --- a/docs/configuration/cheat-sheet.md +++ b/docs/configuration/cheat-sheet.md @@ -4,54 +4,56 @@ aside: false # Configuration Cheat Sheet -| YAML Config Key | Environment Variable | Default value | Description | -|-------------------------|-------------------------------------|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| log-level | OG_LOG_LEVEL | `warn` | Set the log level to one of the following: `debug`, `info`, `warn`, `error`, `fatal`. | -| log-output | OG_LOG_OUTPUT | `stdout,file` | Set the log output to one or more of the following: `stdout`, `file`. | -| external-url | OG_EXTERNAL_URL | none | Public URL to access to Opengist. | -| opengist-home | OG_OPENGIST_HOME | home directory | Path to the directory where Opengist stores its data. | -| secret-key | OG_SECRET_KEY | randomized 32 bytes | Secret key used for session store & encrypt MFA data on database. | -| db-uri | OG_DB_URI | `opengist.db` | URI of the database. | -| index | OG_INDEX | `bleve` | Define the code indexer (either `bleve`, `meilisearch`, or empty for no index). | -| index.meili.host | OG_MEILI_HOST | none | Set the host for the Meiliseach server. | -| index.meili.api-key | OG_MEILI_API_KEY | none | Set the API key for the Meiliseach server. | -| search.default | OG_SEARCH_DEFAULT | `content` | Set the default search fields. Can contain multiple fields (e.g., `content,username`). Fields: `content,user,title,description,filename,extension,language,topic`. | -| git.default-branch | OG_GIT_DEFAULT_BRANCH | none | Default branch name used by Opengist when initializing Git repositories. If not set, uses the Git default branch name. More info [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch) | -| sqlite.journal-mode | OG_SQLITE_JOURNAL_MODE | `WAL` | Set the journal mode for SQLite. More info [here](https://www.sqlite.org/pragma.html#pragma_journal_mode) | -| http.host | OG_HTTP_HOST | `0.0.0.0` | The host on which the HTTP server should bind. Use an IP address for network binding. Use a path for Unix socket binding (e.g. /run/opengist.sock) | -| http.port | OG_HTTP_PORT | `6157` | The port on which the HTTP server should listen. | -| http.git-enabled | OG_HTTP_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via HTTP. (`true` or `false`) | -| api.enabled | OG_API_ENABLED | `true` | Enable or disable the REST API. (`true` or `false`) | -| unix-socket-permissions | OG_UNIX_SOCKET_PERMISSIONS | `0666` | File permissions for Unix socket (octal format). | -| metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable Prometheus metrics server (`true` or `false`) | -| metrics.host | OG_METRICS_HOST | `0.0.0.0` | The host on which the metrics server should bind. | -| metrics.port | OG_METRICS_PORT | `6158` | The port on which the metrics server should listen. | -| ssh.git-enabled | OG_SSH_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via SSH. (`true` or `false`) | -| ssh.host | OG_SSH_HOST | `0.0.0.0` | The host on which the SSH server should bind. | -| ssh.port | OG_SSH_PORT | `2222` | The port on which the SSH server should listen. | -| ssh.external-domain | OG_SSH_EXTERNAL_DOMAIN | none | Public domain for the Git SSH connection, if it has to be different from the HTTP one. If not set, uses the URL from the request. | -| github.client-key | OG_GITHUB_CLIENT_KEY | none | The client key for the GitHub OAuth application. | -| github.secret | OG_GITHUB_SECRET | none | The secret for the GitHub OAuth application. | -| gitlab.client-key | OG_GITLAB_CLIENT_KEY | none | The client key for the GitLab OAuth application. | -| gitlab.secret | OG_GITLAB_SECRET | none | The secret for the GitLab OAuth application. | -| gitlab.url | OG_GITLAB_URL | `https://gitlab.com/` | The URL of the GitLab instance. | -| gitlab.name | OG_GITLAB_NAME | `GitLab` | The name of the GitLab instance. It is displayed in the OAuth login button. | -| gitea.client-key | OG_GITEA_CLIENT_KEY | none | The client key for the Gitea OAuth application. | -| gitea.secret | OG_GITEA_SECRET | none | The secret for the Gitea OAuth application. | -| gitea.url | OG_GITEA_URL | `https://gitea.com/` | The URL of the Gitea instance. | -| gitea.name | OG_GITEA_NAME | `Gitea` | The name of the Gitea instance. It is displayed in the OAuth login button. | -| oidc.provider-name | OG_OIDC_PROVIDER_NAME | none | The name of the OIDC provider | -| oidc.client-key | OG_OIDC_CLIENT_KEY | none | The client key for the OpenID application. | -| oidc.secret | OG_OIDC_SECRET | none | The secret for the OpenID application. | -| oidc.discovery-url | OG_OIDC_DISCOVERY_URL | none | Discovery endpoint of the OpenID provider. | -| oidc.group-claim-name | OG_OIDC_GROUP_CLAIM_NAME | none | Name of the claim containing the groups. | -| oidc.admin-group | OG_OIDC_ADMIN_GROUP | none | Name of the group that should receive admin rights. | -| ldap.url | OG_LDAP_URL | none | URL of the LDAP instance; if not set, LDAP authentication is disabled | -| ldap.bind-dn | OG_LDAP_BIND_DN | none | Bind DN to authenticate against the LDAP. e.g: cn=read-only-admin,dc=example,dc=com | -| ldap.bind-credentials | OG_LDAP_BIND_CREDENTIALS | none | The password for the Bind DN. | -| ldap.search-base | OG_LDAP_SEARCH_BASE | none | The Base DN to start search from. e.g: ou=People,dc=example,dc=com | -| ldap.search-filter | OG_LDAP_SEARCH_FILTER | none | The filter to search against (the format string %s will be replaced with the username). e.g: (uid=%s) | -| custom.name | OG_CUSTOM_NAME | none | The name of your instance, to be displayed in the tab title | -| custom.logo | OG_CUSTOM_LOGO | none | Path to an image, relative to $opengist-home/custom. | -| custom.favicon | OG_CUSTOM_FAVICON | none | Path to an image, relative to $opengist-home/custom. | -| custom.static-links | OG_CUSTOM_STATIC_LINK_#_(PATH,NAME) | none | Path and name to custom links, more info [here](custom-links.md). | +| YAML Config Key | Environment Variable | Default value | Description | +|--------------------------|-------------------------------------|-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| log-level | OG_LOG_LEVEL | `warn` | Set the log level to one of the following: `debug`, `info`, `warn`, `error`, `fatal`. | +| log-output | OG_LOG_OUTPUT | `stdout,file` | Set the log output to one or more of the following: `stdout`, `file`. | +| external-url | OG_EXTERNAL_URL | none | Public URL to access to Opengist. | +| opengist-home | OG_OPENGIST_HOME | home directory | Path to the directory where Opengist stores its data. | +| secret-key | OG_SECRET_KEY | randomized 32 bytes | Secret key used for session store & encrypt MFA data on database. | +| db-uri | OG_DB_URI | `opengist.db` | URI of the database. | +| index | OG_INDEX | `bleve` | Define the code indexer (either `bleve`, `meilisearch`, or empty for no index). | +| index.meili.host | OG_MEILI_HOST | none | Set the host for the Meiliseach server. | +| index.meili.api-key | OG_MEILI_API_KEY | none | Set the API key for the Meiliseach server. | +| search.default | OG_SEARCH_DEFAULT | `content` | Set the default search fields. Can contain multiple fields (e.g., `content,username`). Fields: `content,user,title,description,filename,extension,language,topic`. | +| git.default-branch | OG_GIT_DEFAULT_BRANCH | none | Default branch name used by Opengist when initializing Git repositories. If not set, uses the Git default branch name. More info [here](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_new_default_branch) | +| sqlite.journal-mode | OG_SQLITE_JOURNAL_MODE | `WAL` | Set the journal mode for SQLite. More info [here](https://www.sqlite.org/pragma.html#pragma_journal_mode) | +| http.host | OG_HTTP_HOST | `0.0.0.0` | The host on which the HTTP server should bind. Use an IP address for network binding. Use a path for Unix socket binding (e.g. /run/opengist.sock) | +| http.port | OG_HTTP_PORT | `6157` | The port on which the HTTP server should listen. | +| http.git-enabled | OG_HTTP_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via HTTP. (`true` or `false`) | +| api.enabled | OG_API_ENABLED | `true` | Enable or disable the REST API. (`true` or `false`) | +| unix-socket-permissions | OG_UNIX_SOCKET_PERMISSIONS | `0666` | File permissions for Unix socket (octal format). | +| metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable Prometheus metrics server (`true` or `false`) | +| metrics.host | OG_METRICS_HOST | `0.0.0.0` | The host on which the metrics server should bind. | +| metrics.port | OG_METRICS_PORT | `6158` | The port on which the metrics server should listen. | +| ssh.git-enabled | OG_SSH_GIT_ENABLED | `builtin` | Which SSH server serves git over SSH: `builtin` (Opengist's embedded server), `host` (delegate to the host's OpenSSH), or `disabled` (no SSH git access). Legacy booleans are accepted: `true` maps to `builtin`, `false` to `disabled`. | +| ssh.authorized-keys-file | OG_SSH_AUTHORIZED_KEYS_FILE | none | When `ssh.git-enabled` is `host`, path to an `authorized_keys` file Opengist keeps in sync with users' keys (e.g. `/home/gist/.ssh/authorized_keys`). Leave empty to use OpenSSH's `AuthorizedKeysCommand` instead. | +| ssh.host | OG_SSH_HOST | `0.0.0.0` | The host on which the SSH server should bind. (`builtin` only) | +| ssh.port | OG_SSH_PORT | `2222` | For the `builtin` server, the port it listens on. For `host` mode, the port shown in SSH clone URLs (set it to your OpenSSH port, usually `22`). | +| ssh.external-domain | OG_SSH_EXTERNAL_DOMAIN | none | Public domain for the Git SSH connection, if it has to be different from the HTTP one. If not set, uses the URL from the request. | +| ssh.username | OG_SSH_USERNAME | none | Username shown in SSH clone URLs (e.g. `gist` → `gist@host:user/repo.git`). For `host` mode set it to the OS account clients log in as. If empty, URLs carry no username. | +| github.client-key | OG_GITHUB_CLIENT_KEY | none | The client key for the GitHub OAuth application. | +| github.secret | OG_GITHUB_SECRET | none | The secret for the GitHub OAuth application. | +| gitlab.client-key | OG_GITLAB_CLIENT_KEY | none | The client key for the GitLab OAuth application. | +| gitlab.secret | OG_GITLAB_SECRET | none | The secret for the GitLab OAuth application. | +| gitlab.url | OG_GITLAB_URL | `https://gitlab.com/` | The URL of the GitLab instance. | +| gitlab.name | OG_GITLAB_NAME | `GitLab` | The name of the GitLab instance. It is displayed in the OAuth login button. | +| gitea.client-key | OG_GITEA_CLIENT_KEY | none | The client key for the Gitea OAuth application. | +| gitea.secret | OG_GITEA_SECRET | none | The secret for the Gitea OAuth application. | +| gitea.url | OG_GITEA_URL | `https://gitea.com/` | The URL of the Gitea instance. | +| gitea.name | OG_GITEA_NAME | `Gitea` | The name of the Gitea instance. It is displayed in the OAuth login button. | +| oidc.provider-name | OG_OIDC_PROVIDER_NAME | none | The name of the OIDC provider | +| oidc.client-key | OG_OIDC_CLIENT_KEY | none | The client key for the OpenID application. | +| oidc.secret | OG_OIDC_SECRET | none | The secret for the OpenID application. | +| oidc.discovery-url | OG_OIDC_DISCOVERY_URL | none | Discovery endpoint of the OpenID provider. | +| oidc.group-claim-name | OG_OIDC_GROUP_CLAIM_NAME | none | Name of the claim containing the groups. | +| oidc.admin-group | OG_OIDC_ADMIN_GROUP | none | Name of the group that should receive admin rights. | +| ldap.url | OG_LDAP_URL | none | URL of the LDAP instance; if not set, LDAP authentication is disabled | +| ldap.bind-dn | OG_LDAP_BIND_DN | none | Bind DN to authenticate against the LDAP. e.g: cn=read-only-admin,dc=example,dc=com | +| ldap.bind-credentials | OG_LDAP_BIND_CREDENTIALS | none | The password for the Bind DN. | +| ldap.search-base | OG_LDAP_SEARCH_BASE | none | The Base DN to start search from. e.g: ou=People,dc=example,dc=com | +| ldap.search-filter | OG_LDAP_SEARCH_FILTER | none | The filter to search against (the format string %s will be replaced with the username). e.g: (uid=%s) | +| custom.name | OG_CUSTOM_NAME | none | The name of your instance, to be displayed in the tab title | +| custom.logo | OG_CUSTOM_LOGO | none | Path to an image, relative to $opengist-home/custom. | +| custom.favicon | OG_CUSTOM_FAVICON | none | Path to an image, relative to $opengist-home/custom. | +| custom.static-links | OG_CUSTOM_STATIC_LINK_#_(PATH,NAME) | none | Path and name to custom links, more info [here](custom-links.md). | diff --git a/internal/actions/actions.go b/internal/actions/actions.go index 0df2f40..a88cc1f 100644 --- a/internal/actions/actions.go +++ b/internal/actions/actions.go @@ -13,6 +13,7 @@ import ( "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/index" + "github.com/thomiceli/opengist/internal/ssh" ) const ( @@ -24,6 +25,7 @@ const ( IndexGists SyncGistLanguages DeleteExpiredGists + SyncSSHKeys numActions // keep last — sizes the `running` array ) @@ -50,6 +52,7 @@ var registry = map[int]action{ IndexGists: {run: indexGists}, SyncGistLanguages: {run: syncGistLanguages}, DeleteExpiredGists: {run: deleteExpiredGists, spec: "@every 1m"}, + SyncSSHKeys: {run: syncSSHKeys, spec: "@every 72h"}, } func IsRunning(actionType int) bool { @@ -197,6 +200,16 @@ func syncGistLanguages() { } } +func syncSSHKeys() { + if !config.C.SshManagesAuthorizedKeys() { + return + } + log.Info().Msg("Regenerating the managed authorized_keys file...") + if err := ssh.SyncAuthorizedKeys(); err != nil { + log.Error().Err(err).Msg("Error regenerating the authorized_keys file") + } +} + func deleteExpiredGists() { gists, err := db.DeleteExpiredGists() if err != nil { diff --git a/internal/cli/admin.go b/internal/cli/admin.go index a1d5bcd..2bd5003 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -2,11 +2,27 @@ package cli import ( "fmt" + "io" + + "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/auth/password" + "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/db" "github.com/urfave/cli/v2" ) +func initialize(ctx *cli.Context) { + if err := config.InitConfig(ctx.String("config"), io.Discard); err != nil { + panic(err) + } + config.InitLog() + + db.DeprecationDBFilename() + if err := db.Setup(config.C.DBUri); err != nil { + log.Fatal().Err(err).Msg("Failed to initialize database") + } +} + var CmdAdmin = cli.Command{ Name: "admin", Usage: "Admin commands", diff --git a/internal/cli/hook.go b/internal/cli/hook.go index 1d12cac..c3ae7e8 100644 --- a/internal/cli/hook.go +++ b/internal/cli/hook.go @@ -1,18 +1,16 @@ package cli import ( - "github.com/rs/zerolog/log" - "github.com/thomiceli/opengist/internal/config" - "github.com/thomiceli/opengist/internal/db" + "os" + "github.com/thomiceli/opengist/internal/hooks" "github.com/urfave/cli/v2" - "io" - "os" ) var CmdHook = cli.Command{ - Name: "hook", - Usage: "Run Git server hooks, used and should only be called by Opengist itself", + Name: "hook", + Usage: "Run Git server hooks, used and should only be called by Opengist itself", + Hidden: true, Subcommands: []*cli.Command{ &CmdHookPreReceive, &CmdHookPostReceive, @@ -23,7 +21,7 @@ var CmdHookPreReceive = cli.Command{ Name: "pre-receive", Usage: "Run Git server pre-receive hook for a repository", Action: func(ctx *cli.Context) error { - initialize(ctx) + subprocessInitClient(ctx) if err := hooks.PreReceive(os.Stdin, os.Stdout, os.Stderr); err != nil { os.Exit(1) } @@ -35,22 +33,10 @@ var CmdHookPostReceive = cli.Command{ Name: "post-receive", Usage: "Run Git server post-receive hook for a repository", Action: func(ctx *cli.Context) error { - initialize(ctx) + subprocessInitClient(ctx) if err := hooks.PostReceive(os.Stdin, os.Stdout, os.Stderr); err != nil { os.Exit(1) } return nil }, } - -func initialize(ctx *cli.Context) { - if err := config.InitConfig(ctx.String("config"), io.Discard); err != nil { - panic(err) - } - config.InitLog() - - db.DeprecationDBFilename() - if err := db.Setup(config.C.DBUri); err != nil { - log.Fatal().Err(err).Msg("Failed to initialize database in hooks") - } -} diff --git a/internal/cli/keys.go b/internal/cli/keys.go new file mode 100644 index 0000000..f2d5e86 --- /dev/null +++ b/internal/cli/keys.go @@ -0,0 +1,65 @@ +package cli + +import ( + "fmt" + "os" + "strings" + + "github.com/thomiceli/opengist/internal/ipc" + "github.com/thomiceli/opengist/internal/ssh" + "github.com/urfave/cli/v2" +) + +// CmdKeys is sshd's AuthorizedKeysCommand entry point. For each public key a +// client offers, sshd runs it with the key type and content; if the key is +// known, it prints the matching authorized_keys line (a forced command that +// hands the connection to `opengist shell`). +// +// stdout is parsed by sshd as authorized_keys, so this command writes ONLY the +// key line there — never logs or errors (those go to stderr). The default log +// output includes stdout, so it deliberately avoids the global logger. +// +// Configure sshd with, e.g. (--config is a global flag, so it precedes the +// subcommand): +// +// AuthorizedKeysCommand /usr/local/bin/opengist --config /etc/opengist/config.yml keys -t %t -k %k +// AuthorizedKeysCommandUser opengist +var CmdKeys = cli.Command{ + Name: "keys", + Usage: "Print the authorized_keys line for an SSH key (sshd AuthorizedKeysCommand)", + Hidden: true, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "type", Aliases: []string{"t"}, Usage: "SSH key type, sshd's %t token"}, + &cli.StringFlag{Name: "key", Aliases: []string{"k"}, Usage: "Base64 SSH key, sshd's %k token"}, + }, + Action: func(ctx *cli.Context) error { + subprocessInitClient(ctx) + + keyType := strings.TrimSpace(ctx.String("type")) + keyContent := strings.TrimSpace(ctx.String("key")) + if keyType == "" || keyContent == "" { + // Nothing to match; sshd treats empty output as "no key". + return nil + } + pubKey := keyType + " " + keyContent + + resp, err := ipc.LookupSSHKey(pubKey) + if err != nil { + // Never write to stdout on error: emit no key and report on stderr. + fmt.Fprintln(os.Stderr, "opengist keys: failed to look up SSH key: "+err.Error()) + return nil + } + if !resp.Found { + return nil + } + + exe, err := os.Executable() + if err != nil { + fmt.Fprintln(os.Stderr, "opengist keys: failed to resolve executable path: "+err.Error()) + return nil + } + + fmt.Println(ssh.AuthorizedKeysLine(exe, ctx.String("config"), resp.KeyID, pubKey)) + return nil + }, +} diff --git a/internal/cli/main.go b/internal/cli/main.go index 29b1532..e8455c8 100644 --- a/internal/cli/main.go +++ b/internal/cli/main.go @@ -69,7 +69,7 @@ func App() error { app.Usage = "A self-hosted pastebin powered by Git." app.HelpName = "opengist" - app.Commands = []*cli.Command{&CmdVersion, &CmdStart, &CmdHook, &CmdAdmin} + app.Commands = []*cli.Command{&CmdVersion, &CmdStart, &CmdHook, &CmdAdmin, &CmdKeys, &CmdShell} app.DefaultCommand = CmdStart.Name app.Flags = []cli.Flag{ &ConfigFlag, diff --git a/internal/cli/shell.go b/internal/cli/shell.go new file mode 100644 index 0000000..6a5592e --- /dev/null +++ b/internal/cli/shell.go @@ -0,0 +1,92 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/thomiceli/opengist/internal/ipc" + "github.com/urfave/cli/v2" +) + +// CmdShell is the forced command sshd runs after a key matches (it is embedded +// in the authorized_keys line as `opengist shell `). It authorizes the +// requested git command against the daemon over the IPC API, then runs the git +// pack command locally, streaming the protocol over stdin/stdout. stdout carries +// the git protocol, so nothing else is written there. +var CmdShell = cli.Command{ + Name: "shell", + Usage: "Serve a single git command over SSH (forced command; called by sshd)", + ArgsUsage: "[ssh key id]", + Hidden: true, + Action: func(ctx *cli.Context) error { + subprocessInitClient(ctx) + + code, err := runShell(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "Opengist: "+err.Error()) + os.Exit(1) + } + os.Exit(code) + return nil + }, +} + +func runShell(ctx *cli.Context) (int, error) { + if ctx.NArg() < 1 { + return 1, errors.New("missing SSH key id") + } + keyID, err := strconv.ParseUint(ctx.Args().Get(0), 10, 64) + if err != nil { + return 1, errors.New("invalid SSH key id") + } + + originalCmd := os.Getenv("SSH_ORIGINAL_COMMAND") + if originalCmd == "" { + fmt.Fprintln(os.Stderr, "Hi! You've successfully authenticated to Opengist, but Opengist does not provide shell access.") + return 0, nil + } + + resp, err := ipc.AuthorizeSSHCommand(&ipc.SSHCommandRequest{ + KeyID: uint(keyID), + Command: originalCmd, + IP: sshClientIP(), + }) + if err != nil { + return 1, err + } + if !resp.Authorized { + return 1, errors.New(resp.Message) + } + + // Data plane stays local: run the authorized git pack command against the + // repo on disk. OPENGIST_REPOSITORY_ID lets the post-receive hook update the + // gist over the IPC API, the same way the HTTP push path does. + cmd := exec.Command("git", resp.Verb, resp.RepoPath) + cmd.Dir = resp.RepoPath + cmd.Env = append(os.Environ(), "OPENGIST_REPOSITORY_ID="+resp.GistID) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode(), nil + } + return 1, err + } + return 0, nil +} + +// sshClientIP extracts the connecting client's IP from SSH_CONNECTION +// (" "), for logging. +func sshClientIP() string { + if fields := strings.Fields(os.Getenv("SSH_CONNECTION")); len(fields) > 0 { + return fields[0] + } + return "" +} diff --git a/internal/cli/subprocess.go b/internal/cli/subprocess.go new file mode 100644 index 0000000..1b13ea9 --- /dev/null +++ b/internal/cli/subprocess.go @@ -0,0 +1,31 @@ +package cli + +import ( + "io" + + "github.com/thomiceli/opengist/internal/config" + "github.com/urfave/cli/v2" +) + +// subprocessInit initializes Opengist for short-lived processes that Opengist +// spawns of itself (Git hooks, the SSH `keys`/`shell` commands). It is the +// shared contract for every self-invoked subcommand: +// +// - stdout is reserved for the subcommand's own protocol output (git pack +// stream, authorized_keys lines, hook messages). Config output is discarded, +// and logging is left at zerolog's default (stderr) — InitLog's console +// writer goes to stdout, so it is deliberately not called here. +// - it opens no database: subprocesses talk to the running daemon's internal +// API instead. Use subprocessInitClient when that API is needed. +func subprocessInit(ctx *cli.Context) { + if err := config.InitConfig(ctx.String("config"), io.Discard); err != nil { + panic(err) + } +} + +// subprocessInitClient is subprocessInit plus the secret key, which is needed to +// authenticate calls to the daemon's internal API (see the ipc package). +func subprocessInitClient(ctx *cli.Context) { + subprocessInit(ctx) + config.SetupSecretKey() +} diff --git a/internal/config/config.go b/internal/config/config.go index 1fbc063..b7f39a0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,14 @@ var C *config var SecretKey []byte +// SSH server modes: the canonical values of ssh.git-enabled. The legacy +// booleans are still accepted (true → builtin, false → disabled). +const ( + SshServerBuiltin = "builtin" // Opengist's embedded SSH server + SshServerHost = "host" // delegate to the host's OpenSSH + SshServerDisabled = "disabled" // no SSH git access +) + // Not using nested structs because the library // doesn't support dot notation in this case sadly type config struct { @@ -58,10 +66,12 @@ type config struct { UnixSocketPermissions string `yaml:"unix-socket-permissions" env:"OG_UNIX_SOCKET_PERMISSIONS"` - SshGit bool `yaml:"ssh.git-enabled" env:"OG_SSH_GIT_ENABLED"` - SshHost string `yaml:"ssh.host" env:"OG_SSH_HOST"` - SshPort string `yaml:"ssh.port" env:"OG_SSH_PORT"` - SshExternalDomain string `yaml:"ssh.external-domain" env:"OG_SSH_EXTERNAL_DOMAIN"` + SshGit string `yaml:"ssh.git-enabled" env:"OG_SSH_GIT_ENABLED"` // builtin | host | disabled (true → builtin, false → disabled) + SshAuthorizedKeysFile string `yaml:"ssh.authorized-keys-file" env:"OG_SSH_AUTHORIZED_KEYS_FILE"` + SshHost string `yaml:"ssh.host" env:"OG_SSH_HOST"` + SshPort string `yaml:"ssh.port" env:"OG_SSH_PORT"` + SshExternalDomain string `yaml:"ssh.external-domain" env:"OG_SSH_EXTERNAL_DOMAIN"` + SshUsername string `yaml:"ssh.username" env:"OG_SSH_USERNAME"` GithubClientKey string `yaml:"github.client-key" env:"OG_GITHUB_CLIENT_KEY"` GithubSecret string `yaml:"github.secret" env:"OG_GITHUB_SECRET"` @@ -104,6 +114,22 @@ type StaticLink struct { Path string `yaml:"path" env:"OG_CUSTOM_STATIC_LINK_#_PATH"` } +// SshEnabled reports whether SSH git access is offered in any form. +func (c *config) SshEnabled() bool { + return c.SshGit == SshServerBuiltin || c.SshGit == SshServerHost +} + +// SshBuiltin reports whether Opengist runs its own embedded SSH server. +func (c *config) SshBuiltin() bool { + return c.SshGit == SshServerBuiltin +} + +// SshManagesAuthorizedKeys reports whether Opengist maintains an authorized_keys +// file: host mode with a configured file path. +func (c *config) SshManagesAuthorizedKeys() bool { + return c.SshGit == SshServerHost && c.SshAuthorizedKeysFile != "" +} + func configWithDefaults() (*config, error) { c := &config{} @@ -126,7 +152,7 @@ func configWithDefaults() (*config, error) { c.UnixSocketPermissions = "0666" - c.SshGit = true + c.SshGit = SshServerBuiltin c.SshHost = "0.0.0.0" c.SshPort = "2222" @@ -157,6 +183,11 @@ func InitConfig(configPath string, out io.Writer) error { return err } + // ssh.git-enabled accepts the explicit modes (builtin, host, disabled) as well + // as the legacy booleans (true → builtin, false → disabled). Collapse whatever + // was provided into a canonical mode. + c.SshGit = normalizeSshGitMode(c.SshGit) + if c.OpengistHome == "" { homeDir, err := os.UserHomeDir() if err != nil { @@ -393,7 +424,30 @@ func loadConfigFromEnv(c *config, out io.Writer) error { return nil } +// normalizeSshGitMode maps every accepted ssh.git-enabled value to a canonical +// mode. The legacy booleans are preserved for backward compatibility (true → +// builtin, false → disabled); unknown values are returned untouched so checks() +// can reject them. +func normalizeSshGitMode(s string) string { + switch strings.ToLower(strings.TrimSpace(s)) { + case "host": + return SshServerHost + case "false", "f", "0", "no", "off", "disabled": + return SshServerDisabled + case "true", "t", "1", "yes", "on", "builtin", "": + return SshServerBuiltin + default: + return strings.TrimSpace(s) + } +} + func checks(c *config) error { + switch c.SshGit { + case SshServerBuiltin, SshServerHost, SshServerDisabled: + default: + return fmt.Errorf("invalid ssh.git-enabled %q (must be %q, %q or %q, or a boolean)", c.SshGit, SshServerBuiltin, SshServerHost, SshServerDisabled) + } + if _, err := url.Parse(c.ExternalUrl); err != nil { return err } diff --git a/internal/db/gist.go b/internal/db/gist.go index 3719e68..0e8621a 100644 --- a/internal/db/gist.go +++ b/internal/db/gist.go @@ -840,20 +840,24 @@ func (gist *Gist) HTTPCloneURL(baseURL string) string { // SSHCloneURL returns the SSH clone URL. `fallbackHost` is the request's Host // header (or any host:port-shaped string) used when SshExternalDomain isn't // configured — only its hostname part is kept. Returns "" when SSH git access -// is disabled (config.SshGit == false). +// is disabled (ssh.git-enabled = disabled). func (gist *Gist) SSHCloneURL(fallbackHost string) string { - if !config.C.SshGit { + if !config.C.SshEnabled() { return "" } sshDomain := config.C.SshExternalDomain if sshDomain == "" { sshDomain = strings.Split(fallbackHost, ":")[0] } + var user string + if config.C.SshUsername != "" { + user = config.C.SshUsername + "@" + } path := gist.User.Username + "/" + gist.Identifier() + ".git" if config.C.SshPort == "22" { - return sshDomain + ":" + path + return user + sshDomain + ":" + path } - return "ssh://" + sshDomain + ":" + config.C.SshPort + "/" + path + return "ssh://" + user + sshDomain + ":" + config.C.SshPort + "/" + path } func (gist *Gist) GetLanguagesFromFiles() ([]string, error) { diff --git a/internal/db/sshkey.go b/internal/db/sshkey.go index 0ff1a2a..b309dd3 100644 --- a/internal/db/sshkey.go +++ b/internal/db/sshkey.go @@ -48,6 +48,24 @@ func GetSSHKeyByID(sshKeyId uint) (*SSHKey, error) { return sshKey, err } +func GetAllSSHKeys() ([]*SSHKey, error) { + var sshKeys []*SSHKey + err := db. + Order("id asc"). + Find(&sshKeys).Error + + return sshKeys, err +} + +func GetSSHKeyByContent(sshKeyContent string) (*SSHKey, error) { + sshKey := new(SSHKey) + err := db. + Where("content = ?", sshKeyContent). + First(&sshKey).Error + + return sshKey, err +} + func SSHKeyDoesExists(sshKeyContent string) (bool, error) { var count int64 err := db.Model(&SSHKey{}). diff --git a/internal/hooks/post_receive.go b/internal/hooks/post_receive.go index 5872fc6..fd8c996 100644 --- a/internal/hooks/post_receive.go +++ b/internal/hooks/post_receive.go @@ -10,41 +10,76 @@ import ( "strings" "time" + "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/git" + "github.com/thomiceli/opengist/internal/ipc" validatorpkg "github.com/thomiceli/opengist/internal/validator" ) +// PostReceive is the client side of the post-receive hook. It runs in the +// short-lived hook subprocess: it gathers the ref updates from stdin and the +// push options from the environment, forwards them to the running daemon's +// internal API (which holds the warm database connection and the index), and +// prints back whatever message the daemon produced. It opens no database. func PostReceive(in io.Reader, out, er io.Writer) error { - var outputSb strings.Builder - newGist := false - opts := pushOptions() - gistUrl := os.Getenv("OPENGIST_REPOSITORY_URL_INTERNAL") - validator := validatorpkg.NewValidator() + gistID := os.Getenv("OPENGIST_REPOSITORY_ID") + if gistID == "" { + // No gist id is set on the SSH push path, which performs the gist update + // in-process instead. Nothing for this hook to forward. + return nil + } + + var refs []ipc.HookRefUpdate scanner := bufio.NewScanner(in) for scanner.Scan() { - line := scanner.Text() - parts := strings.Fields(line) + parts := strings.Fields(scanner.Text()) if len(parts) != 3 { _, _ = fmt.Fprintln(er, "Invalid input") return fmt.Errorf("invalid input") } - oldrev, _, refname := parts[0], parts[1], parts[2] + refs = append(refs, ipc.HookRefUpdate{OldRev: parts[0], NewRev: parts[1], RefName: parts[2]}) + } - if err := verifyHEAD(); err != nil { - setSymbolicRef(refname) + resp, err := ipc.HookPostReceive(&ipc.HookPostReceiveRequest{ + GistID: gistID, + GistURL: os.Getenv("OPENGIST_REPOSITORY_URL_INTERNAL"), + References: refs, + PushOptions: pushOptions(), + }) + if err != nil { + _, _ = fmt.Fprintln(er, err.Error()) + return err + } + + if resp.Output != "" { + _, _ = fmt.Fprint(out, "\n"+resp.Output) + } + + return nil +} + +// RunPostReceive is the server side of the post-receive hook. It runs inside the +// daemon (warm database connection and search index) on behalf of the hook +// subprocess, and returns the message to show the user pushing. +func RunPostReceive(gist *db.Gist, repoDir, gistUrl string, refs []ipc.HookRefUpdate, opts map[string]string) (string, error) { + var outputSb strings.Builder + newGist := false + validator := validatorpkg.NewValidator() + + for _, ref := range refs { + if err := verifyHEAD(repoDir); err != nil { + setSymbolicRef(repoDir, ref.RefName) } - if oldrev == BaseHash { + if ref.OldRev == BaseHash { newGist = true } } - gist, err := db.GetGistByID(os.Getenv("OPENGIST_REPOSITORY_ID")) - if err != nil { - _, _ = fmt.Fprintln(er, "Failed to get gist") - return fmt.Errorf("failed to get gist: %w", err) + if gistUrl == "" { + gistUrl = strings.TrimSuffix(config.C.ExternalUrl, "/") + "/" + gist.User.Username + "/" + gist.Identifier() } if slices.Contains([]string{"public", "unlisted", "private"}, opts["visibility"]) { @@ -103,20 +138,16 @@ func PostReceive(in io.Reader, out, er io.Writer) error { } if hasNoCommits, err := git.HasNoCommits(gist.User.Username, gist.Uuid); err != nil { - _, _ = fmt.Fprintln(er, "Failed to check if gist has no commits") - return fmt.Errorf("failed to check if gist has no commits: %w", err) + return "", fmt.Errorf("failed to check if gist has no commits: %w", err) } else if hasNoCommits { if err = gist.Delete(); err != nil { - _, _ = fmt.Fprintln(er, "Failed to delete gist") - return fmt.Errorf("failed to delete gist: %w", err) + return "", fmt.Errorf("failed to delete gist: %w", err) } } _ = gist.SetLastActiveNow() - err = gist.UpdatePreviewAndCount(true) - if err != nil { - _, _ = fmt.Fprintln(er, "Failed to update gist") - return fmt.Errorf("failed to update gist: %w", err) + if err := gist.UpdatePreviewAndCount(true); err != nil { + return "", fmt.Errorf("failed to update gist: %w", err) } gist.AddInIndex() @@ -127,18 +158,17 @@ func PostReceive(in io.Reader, out, er io.Writer) error { fmt.Fprintf(&outputSb, "git remote set-url origin %s\n\n", gistUrl) } - outputStr := outputSb.String() - if outputStr != "" { - _, _ = fmt.Fprint(out, "\n"+outputStr) - } - - return nil + return outputSb.String(), nil } -func verifyHEAD() error { - return exec.Command("git", "rev-parse", "--verify", "--quiet", "HEAD").Run() +func verifyHEAD(dir string) error { + cmd := exec.Command("git", "rev-parse", "--verify", "--quiet", "HEAD") + cmd.Dir = dir + return cmd.Run() } -func setSymbolicRef(refname string) { - _ = exec.Command("git", "symbolic-ref", "HEAD", refname).Run() +func setSymbolicRef(dir, refname string) { + cmd := exec.Command("git", "symbolic-ref", "HEAD", refname) + cmd.Dir = dir + _ = cmd.Run() } diff --git a/internal/hooks/pre_receive.go b/internal/hooks/pre_receive.go index 899263c..48ac067 100644 --- a/internal/hooks/pre_receive.go +++ b/internal/hooks/pre_receive.go @@ -7,12 +7,17 @@ import ( "io" "os/exec" "strings" + + "github.com/thomiceli/opengist/internal/ipc" ) +// PreReceive is the client side of the pre-receive hook. It runs in the +// short-lived hook subprocess, where Git's push quarantine makes the incoming +// objects visible, so it computes the changed files locally and forwards them to +// the running daemon's internal API, which applies the push policy. It opens no +// database. func PreReceive(in io.Reader, out, er io.Writer) error { - var err error - var disallowedFiles []string - var disallowedCommits []string + var changedFilesPerRef []string scanner := bufio.NewScanner(in) for scanner.Scan() { @@ -25,20 +30,45 @@ func PreReceive(in io.Reader, out, er io.Writer) error { oldRev, newRev := parts[0], parts[1] - var changedFiles string + var rev string if oldRev == BaseHash { // First commit - if changedFiles, err = getChangedFiles(newRev); err != nil { - _, _ = fmt.Fprintln(er, "Failed to get changed files") - return err - } + rev = newRev } else { - if changedFiles, err = getChangedFiles(fmt.Sprintf("%s..%s", oldRev, newRev)); err != nil { - _, _ = fmt.Fprintln(er, "Failed to get changed files") - return err - } + rev = fmt.Sprintf("%s..%s", oldRev, newRev) } + changedFiles, err := getChangedFiles(rev) + if err != nil { + _, _ = fmt.Fprintln(er, "Failed to get changed files") + return err + } + changedFilesPerRef = append(changedFilesPerRef, changedFiles) + } + + resp, err := ipc.HookPreReceive(&ipc.HookPreReceiveRequest{ChangedFiles: changedFilesPerRef}) + if err != nil { + _, _ = fmt.Fprintln(er, err.Error()) + return err + } + + if !resp.Allowed { + _, _ = fmt.Fprint(out, resp.Message) + return fmt.Errorf("push rejected") + } + + return nil +} + +// RunPreReceive is the server side of the pre-receive hook. It runs inside the +// daemon and decides whether a push may proceed, given the changed files the +// subprocess computed (one raw `git log` output per ref). Opengist gists are +// flat, so pushing files inside directories is rejected. +func RunPreReceive(changedFilesPerRef []string) (bool, string) { + var disallowedFiles []string + var disallowedCommits []string + + for _, changedFiles := range changedFilesPerRef { var currentCommit string for _, file := range strings.Fields(changedFiles) { if strings.HasPrefix(file, "/") { @@ -53,15 +83,16 @@ func PreReceive(in io.Reader, out, er io.Writer) error { } if len(disallowedFiles) > 0 { - _, _ = fmt.Fprintln(out, "\nPushing files in directories is not allowed:") + var sb strings.Builder + _, _ = fmt.Fprintln(&sb, "\nPushing files in directories is not allowed:") for i := range disallowedFiles { - _, _ = fmt.Fprintf(out, " %s (%s)\n", disallowedFiles[i], disallowedCommits[i]) + _, _ = fmt.Fprintf(&sb, " %s (%s)\n", disallowedFiles[i], disallowedCommits[i]) } - _, _ = fmt.Fprintln(out) - return fmt.Errorf("pushing files in directories is not allowed: %s", disallowedFiles) + _, _ = fmt.Fprintln(&sb) + return false, sb.String() } - return nil + return true, "" } func getChangedFiles(rev string) (string, error) { diff --git a/internal/hooks/pre_receive_test.go b/internal/hooks/pre_receive_test.go deleted file mode 100644 index 118e3d6..0000000 --- a/internal/hooks/pre_receive_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package hooks - -import ( - "bytes" - "fmt" - "github.com/stretchr/testify/require" - "github.com/thomiceli/opengist/internal/git" - "os" - "testing" -) - -func TestPreReceiveHook(t *testing.T) { - git.SetupTest(t) - defer git.TeardownTest(t) - var lastCommitHash string - err := os.Chdir(git.RepositoryPath("thomas", "gist1")) - require.NoError(t, err, "Could not change directory") - - git.CommitToBare(t, "thomas", "gist1", map[string]string{ - "my_file.txt": "some allowed file", - "my_file2.txt": "some allowed file\nagain", - }) - lastCommitHash = git.LastHashOfCommit(t, "thomas", "gist1") - err = PreReceive(bytes.NewBufferString(fmt.Sprintf("%s %s %s", BaseHash, lastCommitHash, "refs/heads/master")), os.Stdout, os.Stderr) - require.NoError(t, err, "Should not have an error on pre-receive hook for commit+push 1") - - git.CommitToBare(t, "thomas", "gist1", map[string]string{ - "my_file.txt": "some allowed file", - "dir/my_file.txt": "some disallowed file suddenly", - }) - lastCommitHash = git.LastHashOfCommit(t, "thomas", "gist1") - err = PreReceive(bytes.NewBufferString(fmt.Sprintf("%s %s %s", BaseHash, lastCommitHash, "refs/heads/master")), os.Stdout, os.Stderr) - require.Error(t, err, "Should have an error on pre-receive hook for commit+push 2") - require.Equal(t, "pushing files in directories is not allowed: [dir/my_file.txt]", err.Error(), "Error message is not correct") - - git.CommitToBare(t, "thomas", "gist1", map[string]string{ - "my_file.txt": "some allowed file", - "dir/ok/afileagain.txt": "some disallowed file\nagain", - }) - lastCommitHash = git.LastHashOfCommit(t, "thomas", "gist1") - err = PreReceive(bytes.NewBufferString(fmt.Sprintf("%s %s %s", BaseHash, lastCommitHash, "refs/heads/master")), os.Stdout, os.Stderr) - require.Error(t, err, "Should have an error on pre-receive hook for commit+push 3") - require.Equal(t, "pushing files in directories is not allowed: [dir/ok/afileagain.txt dir/my_file.txt]", err.Error(), "Error message is not correct") - - git.CommitToBare(t, "thomas", "gist1", map[string]string{ - "allowedfile.txt": "some allowed file only", - }) - lastCommitHash = git.LastHashOfCommit(t, "thomas", "gist1") - err = PreReceive(bytes.NewBufferString(fmt.Sprintf("%s %s %s", BaseHash, lastCommitHash, "refs/heads/master")), os.Stdout, os.Stderr) - require.Error(t, err, "Should have an error on pre-receive hook for commit+push 4") - require.Equal(t, "pushing files in directories is not allowed: [dir/ok/afileagain.txt dir/my_file.txt]", err.Error(), "Error message is not correct") - - _ = os.Chdir(os.TempDir()) // Leave the current dir to avoid errors on teardown -} diff --git a/internal/i18n/locales/en-US.yml b/internal/i18n/locales/en-US.yml index a1fe72f..7428415 100644 --- a/internal/i18n/locales/en-US.yml +++ b/internal/i18n/locales/en-US.yml @@ -313,6 +313,7 @@ admin.actions.reset-hooks: Reset Git server hooks for all repositories admin.actions.index-gists: Rebuild search index admin.actions.sync-gist-languages: Synchronize all gists languages admin.actions.delete-expired-gists: Delete expired gists +admin.actions.sync-ssh-keys: Regenerate the authorized_keys file admin.id: ID admin.user: User admin.delete: Delete @@ -362,6 +363,7 @@ flash.admin.reset-hooks: Resetting Git server hooks for all repositories... flash.admin.index-gists: Rebuilding search index... flash.admin.sync-gist-languages: Syncing Gist languages... flash.admin.delete-expired-gists: Deleting expired gists... +flash.admin.sync-ssh-keys: Regenerating the authorized_keys file... flash.auth.username-exists: Username already exists flash.auth.invalid-credentials: Invalid credentials diff --git a/internal/ipc/hooks.go b/internal/ipc/hooks.go new file mode 100644 index 0000000..3c79ac1 --- /dev/null +++ b/internal/ipc/hooks.go @@ -0,0 +1,59 @@ +package ipc + +// HookRefUpdate is one line of a Git hook's stdin: the old/new revision of a +// ref being updated. +type HookRefUpdate struct { + OldRev string `json:"old_rev"` + NewRev string `json:"new_rev"` + RefName string `json:"ref_name"` +} + +// HookPreReceiveRequest is sent by the pre-receive hook subprocess to the +// daemon. The subprocess computes ChangedFiles itself — one raw `git log` +// output per updated ref — because the pushed objects are only visible to the +// hook's environment (push quarantine) until pre-receive succeeds; the daemon +// only applies the policy. +type HookPreReceiveRequest struct { + ChangedFiles []string `json:"changed_files"` +} + +// HookPreReceiveResponse carries the daemon's decision and, when rejected, the +// message to show the user pushing. +type HookPreReceiveResponse struct { + Allowed bool `json:"allowed"` + Message string `json:"message"` +} + +// HookPreReceive asks the daemon whether a push may proceed. +func HookPreReceive(req *HookPreReceiveRequest) (*HookPreReceiveResponse, error) { + resp := &HookPreReceiveResponse{} + if err := post("/api/ipc/hook/pre-receive", req, resp); err != nil { + return nil, err + } + return resp, nil +} + +// HookPostReceiveRequest is sent by the post-receive hook subprocess to the +// daemon. +type HookPostReceiveRequest struct { + GistID string `json:"gist_id"` + GistURL string `json:"gist_url"` + References []HookRefUpdate `json:"references"` + PushOptions map[string]string `json:"push_options"` +} + +// HookPostReceiveResponse carries the text the daemon wants shown to the user +// pushing. +type HookPostReceiveResponse struct { + Output string `json:"output"` +} + +// HookPostReceive forwards a post-receive event to the daemon, which performs +// the database and index work and returns any message to show the user. +func HookPostReceive(req *HookPostReceiveRequest) (*HookPostReceiveResponse, error) { + resp := &HookPostReceiveResponse{} + if err := post("/api/ipc/hook/post-receive", req, resp); err != nil { + return nil, err + } + return resp, nil +} diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go new file mode 100644 index 0000000..ebf3391 --- /dev/null +++ b/internal/ipc/ipc.go @@ -0,0 +1,101 @@ +// Package ipc is the client side of Opengist's internal API: the small HTTP +// surface that short-lived subprocesses Opengist spawns of itself (Git hooks, +// and the SSH shim) use to talk to the long-running daemon. +// +// Subprocesses do not open the database. Instead they call the running daemon — +// which holds the warm connection pool and the search index — over its existing +// HTTP listener, authenticated with a token derived from the secret key. This +// avoids paying a fresh DB connection (and, previously, a full AutoMigrate) on +// every invocation. It mirrors Gitea's private/internal API. +// +// This file holds the shared transport and auth; the per-feature calls live in +// hooks.go and keys.go. +package ipc + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + + "github.com/thomiceli/opengist/internal/config" +) + +// AuthHeader carries the internal API token on requests from subprocesses to +// the daemon. +const AuthHeader = "X-Opengist-Internal" + +// Token derives the shared secret used to authenticate internal API calls. It +// is derived from — not equal to — the session SecretKey, so the raw session +// secret never travels on the wire. The daemon and its subprocesses compute the +// same value because they load the same SecretKey. +func Token() string { + mac := hmac.New(sha256.New, config.SecretKey) + mac.Write([]byte("opengist-internal-api")) + return hex.EncodeToString(mac.Sum(nil)) +} + +// client builds an HTTP client and base URL targeting the running daemon's +// listener, whether it is bound to a TCP address or a unix socket. On Windows +// the daemon is always on TCP, so the unix-socket branch is never taken there. +func client() (*http.Client, string) { + host := config.C.HttpHost + port := config.C.HttpPort + + if strings.ContainsAny(host, `/\`) { + socket := host + return &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socket) + }, + }, + }, "http://unix" + } + + if host == "0.0.0.0" || host == "::" || host == "" { + host = "127.0.0.1" + } + return &http.Client{}, "http://" + host + ":" + port +} + +func post(path string, reqBody, respBody any) error { + httpClient, baseURL := client() + + var buf bytes.Buffer + if reqBody != nil { + if err := json.NewEncoder(&buf).Encode(reqBody); err != nil { + return err + } + } + + httpReq, err := http.NewRequest(http.MethodPost, baseURL+path, &buf) + if err != nil { + return err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set(AuthHeader, Token()) + + resp, err := httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("could not reach the Opengist server's internal API (is it running?): %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("internal API error (%d): %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + if respBody != nil { + return json.NewDecoder(resp.Body).Decode(respBody) + } + return nil +} diff --git a/internal/ipc/keys.go b/internal/ipc/keys.go new file mode 100644 index 0000000..e04e6a2 --- /dev/null +++ b/internal/ipc/keys.go @@ -0,0 +1,25 @@ +package ipc + +// SSHKeyLookupRequest asks the daemon to resolve a public key (in authorized +// keys form, " ") to its stored SSH key. Used by the `keys` +// command, which sshd runs as its AuthorizedKeysCommand on every offered key — +// the hot, pre-auth path — so it must not open the database itself. +type SSHKeyLookupRequest struct { + Key string `json:"key"` +} + +// SSHKeyLookupResponse reports whether the key is known and, if so, its id (to +// embed in the forced command). +type SSHKeyLookupResponse struct { + Found bool `json:"found"` + KeyID uint `json:"key_id"` +} + +// LookupSSHKey resolves a public key against the daemon's database. +func LookupSSHKey(key string) (*SSHKeyLookupResponse, error) { + resp := &SSHKeyLookupResponse{} + if err := post("/api/ipc/ssh/keys", &SSHKeyLookupRequest{Key: key}, resp); err != nil { + return nil, err + } + return resp, nil +} diff --git a/internal/ipc/shell.go b/internal/ipc/shell.go new file mode 100644 index 0000000..0313a45 --- /dev/null +++ b/internal/ipc/shell.go @@ -0,0 +1,31 @@ +package ipc + +// SSHCommandRequest asks the daemon to authorize a git command for a connecting +// SSH key (identified by the id embedded in the forced command) and report what +// to run. Used by the `shell` command — the forced command sshd runs after a key +// matches. +type SSHCommandRequest struct { + KeyID uint `json:"key_id"` + Command string `json:"command"` + IP string `json:"ip"` +} + +// SSHCommandResponse carries the daemon's decision. When authorized, it tells +// the shim which git pack command to run and against which repository; when not, +// Message is shown to the connecting user. +type SSHCommandResponse struct { + Authorized bool `json:"authorized"` + Message string `json:"message"` + Verb string `json:"verb"` + RepoPath string `json:"repo_path"` + GistID string `json:"gist_id"` +} + +// AuthorizeSSHCommand authorizes an SSH git command against the daemon. +func AuthorizeSSHCommand(req *SSHCommandRequest) (*SSHCommandResponse, error) { + resp := &SSHCommandResponse{} + if err := post("/api/ipc/ssh/command", req, resp); err != nil { + return nil, err + } + return resp, nil +} diff --git a/internal/ssh/authorized_keys.go b/internal/ssh/authorized_keys.go new file mode 100644 index 0000000..8793380 --- /dev/null +++ b/internal/ssh/authorized_keys.go @@ -0,0 +1,151 @@ +package ssh + +import ( + "bufio" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + + "github.com/rs/zerolog/log" + "github.com/thomiceli/opengist/internal/config" + "github.com/thomiceli/opengist/internal/db" +) + +// authorizedKeysOptions are the SSH restrictions applied to every Opengist key +// entry: the connection may only run the forced git command, nothing else. +const authorizedKeysOptions = "no-port-forwarding,no-x11-forwarding,no-agent-forwarding,no-pty" + +// Markers delimiting the block Opengist manages inside the authorized_keys file. +// Anything outside them (e.g. hand-added keys) is preserved across syncs. +const ( + authorizedKeysBegin = "# --- opengist managed keys start (do not edit) ---" + authorizedKeysEnd = "# --- opengist managed keys end ---" +) + +var authorizedKeysMu sync.Mutex + +// AuthorizedKeysLine builds a single authorized_keys entry for an Opengist key: +// a forced command running `opengist shell ` (so the daemon knows which +// key is connecting), restricted to git, followed by the public key itself. +// +// It is shared by the `keys` command (AuthorizedKeysCommand mode) and the +// managed authorized_keys file writer. execPath is the absolute path to the +// Opengist binary and configPath the optional --config to propagate. +func AuthorizedKeysLine(execPath, configPath string, keyID uint, pubKey string) string { + var cmd strings.Builder + cmd.WriteString(execPath) + if configPath != "" { + cmd.WriteString(" --config ") + cmd.WriteString(configPath) + } + cmd.WriteString(" shell ") + cmd.WriteString(strconv.FormatUint(uint64(keyID), 10)) + + return `command="` + cmd.String() + `",` + authorizedKeysOptions + " " + strings.TrimSpace(pubKey) +} + +// SyncAuthorizedKeys rewrites Opengist's managed block in the configured +// authorized_keys file from every SSH key in the database, preserving any other +// lines in the file. It is a no-op unless host mode with a configured file path +// (config.SshManagesAuthorizedKeys). Safe for concurrent callers. +func SyncAuthorizedKeys() error { + if !config.C.SshManagesAuthorizedKeys() { + return nil + } + + authorizedKeysMu.Lock() + defer authorizedKeysMu.Unlock() + + keys, err := db.GetAllSSHKeys() + if err != nil { + return err + } + + // Reuse the stable symlinks (as the Git hooks do) so the forced command keeps + // working across binary upgrades. + exe := filepath.Join(config.GetHomeDir(), "symlinks", "opengist") + cfg := filepath.Join(config.GetHomeDir(), "symlinks", "config.yml") + + var block strings.Builder + block.WriteString(authorizedKeysBegin + "\n") + for _, k := range keys { + block.WriteString(AuthorizedKeysLine(exe, cfg, k.ID, k.Content) + "\n") + } + block.WriteString(authorizedKeysEnd + "\n") + + return writeAuthorizedKeysFile(config.C.SshAuthorizedKeysFile, block.String()) +} + +// SyncAuthorizedKeysLogged runs SyncAuthorizedKeys and logs any error. It is for +// callers that should not fail their own operation when the sync fails: the +// database is already updated, and the next change or a restart reconciles. +func SyncAuthorizedKeysLogged() { + if err := SyncAuthorizedKeys(); err != nil { + log.Error().Err(err).Msg("Failed to sync the authorized_keys file") + } +} + +// writeAuthorizedKeysFile replaces the Opengist-managed block (between the +// marker lines) in path with managedBlock, preserving all other lines, and +// writes the result atomically with sshd-compatible permissions (dir 0700, +// file 0600 — sshd's StrictModes ignores looser ones). +func writeAuthorizedKeysFile(path, managedBlock string) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + + var preserved []string + if existing, err := os.Open(path); err == nil { + scanner := bufio.NewScanner(existing) + inBlock := false + for scanner.Scan() { + line := scanner.Text() + switch line { + case authorizedKeysBegin: + inBlock = true + case authorizedKeysEnd: + inBlock = false + default: + if !inBlock { + preserved = append(preserved, line) + } + } + } + _ = existing.Close() + if err := scanner.Err(); err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + + var out strings.Builder + for _, line := range preserved { + out.WriteString(line + "\n") + } + out.WriteString(managedBlock) + + tmp, err := os.CreateTemp(dir, ".authorized_keys-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + + if _, err := tmp.WriteString(out.String()); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(0600); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + + return os.Rename(tmpName, path) +} diff --git a/internal/ssh/git_ssh.go b/internal/ssh/git_ssh.go index f2ba6ab..8b22970 100644 --- a/internal/ssh/git_ssh.go +++ b/internal/ssh/git_ssh.go @@ -10,11 +10,15 @@ import ( "github.com/thomiceli/opengist/internal/auth" "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/git" - "golang.org/x/crypto/ssh" "gorm.io/gorm" ) -func runGitCommand(ch ssh.Channel, gitCmd string, key string, ip string) error { +// AuthorizeGitCommand validates a single git pack command (upload-pack / +// receive-pack) and checks that the given SSH key may run it against the +// referenced gist, returning the gist and the bare verb. It is shared by the +// embedded SSH server (which has the key string) and the IPC handler backing the +// OpenSSH shim (which resolves the key id to its string). +func AuthorizeGitCommand(gitCmd string, key string, ip string) (*db.Gist, string, error) { verb, args := parseCommand(gitCmd) if !strings.HasPrefix(verb, "git-") { verb = "" @@ -22,13 +26,13 @@ func runGitCommand(ch ssh.Channel, gitCmd string, key string, ip string) error { verb = strings.TrimPrefix(verb, "git-") if verb != "upload-pack" && verb != "receive-pack" { - return errors.New("invalid command") + return nil, "", errors.New("invalid command") } repoFullName := strings.ToLower(strings.Trim(args, "'")) repoFields := strings.SplitN(repoFullName, "/", 2) if len(repoFields) != 2 { - return errors.New("invalid gist path") + return nil, "", errors.New("invalid gist path") } userName := strings.ToLower(repoFields[0]) @@ -36,13 +40,13 @@ func runGitCommand(ch ssh.Channel, gitCmd string, key string, ip string) error { gist, err := db.GetGist(userName, gistName) if err != nil { - return errors.New("gist not found") + return nil, "", errors.New("gist not found") } allowUnauthenticated, err := auth.ShouldAllowUnauthenticatedGistAccess(db.AuthInfo{}, true) if err != nil { errorSsh("Failed to get auth info", err) - return errors.New("internal server error") + return nil, "", errors.New("internal server error") } // Check for the key if : @@ -66,14 +70,28 @@ func runGitCommand(ch ssh.Channel, gitCmd string, key string, ip string) error { if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { log.Warn().Msg("Invalid SSH authentication attempt from " + ip) - return errors.New("gist not found") + return nil, "", errors.New("gist not found") } errorSsh("Failed to get user by SSH key id", err) - return errors.New("internal server error") + return nil, "", errors.New("internal server error") } _ = db.SSHKeyLastUsedNow(pubKey.Content) } + return gist, verb, nil +} + +// RunGitCommand authorizes and runs a single git pack command (upload-pack / +// receive-pack) for the gist referenced by gitCmd, on behalf of the given SSH +// key. It is transport-agnostic: the embedded SSH server passes its channel as +// in/out/errOut. (The OpenSSH shim authorizes over the IPC API and runs git +// itself, so it does not use this.) +func RunGitCommand(in io.Reader, out, errOut io.Writer, gitCmd string, key string, ip string) error { + gist, verb, err := AuthorizeGitCommand(gitCmd, key, ip) + if err != nil { + return err + } + repositoryPath := git.RepositoryPath(gist.User.Username, gist.Uuid) cmd := exec.Command("git", verb, repositoryPath) @@ -90,10 +108,10 @@ func runGitCommand(ch ssh.Channel, gitCmd string, key string, ip string) error { // avoid blocking go func() { - _, _ = io.Copy(stdin, ch) + _, _ = io.Copy(stdin, in) }() - _, _ = io.Copy(ch, stdout) - _, _ = io.Copy(ch, stderr) + _, _ = io.Copy(out, stdout) + _, _ = io.Copy(errOut, stderr) err = cmd.Wait() if err != nil { diff --git a/internal/ssh/run.go b/internal/ssh/run.go index 370fa41..e49fbc2 100644 --- a/internal/ssh/run.go +++ b/internal/ssh/run.go @@ -19,7 +19,7 @@ import ( ) func Start() { - if !config.C.SshGit { + if !config.C.SshBuiltin() { return } @@ -110,7 +110,7 @@ func handleConnexion(channels <-chan ssh.NewChannel, key string, ip string) { payloadCmd = payloadCmd[i:] } - if err = runGitCommand(ch, payloadCmd, key, ip); err != nil { + if err = RunGitCommand(ch, ch, ch, payloadCmd, key, ip); err != nil { _, _ = ch.Stderr().Write([]byte("Opengist: " + err.Error() + "\r\n")) } _, _ = ch.SendRequest("exit-status", false, []byte{0, 0, 0, 0}) diff --git a/internal/web/handlers/admin/actions.go b/internal/web/handlers/admin/actions.go index f4a8f45..ceec380 100644 --- a/internal/web/handlers/admin/actions.go +++ b/internal/web/handlers/admin/actions.go @@ -52,3 +52,9 @@ func AdminDeleteExpiredGists(ctx *context.Context) error { go actions.RunOnce(actions.DeleteExpiredGists) return ctx.RedirectTo("/admin-panel") } + +func AdminSyncSSHKeys(ctx *context.Context) error { + ctx.AddFlash(ctx.Tr("flash.admin.sync-ssh-keys"), "success") + go actions.RunOnce(actions.SyncSSHKeys) + return ctx.RedirectTo("/admin-panel") +} diff --git a/internal/web/handlers/admin/admin.go b/internal/web/handlers/admin/admin.go index b2781c4..653a472 100644 --- a/internal/web/handlers/admin/admin.go +++ b/internal/web/handlers/admin/admin.go @@ -9,6 +9,7 @@ import ( "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/git" + opengistssh "github.com/thomiceli/opengist/internal/ssh" "github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/handlers" ) @@ -51,6 +52,8 @@ func AdminIndex(ctx *context.Context) error { ctx.SetData("indexGists", actions.IsRunning(actions.IndexGists)) ctx.SetData("syncGistLanguages", actions.IsRunning(actions.SyncGistLanguages)) ctx.SetData("deleteExpiredGists", actions.IsRunning(actions.DeleteExpiredGists)) + ctx.SetData("syncSSHKeys", actions.IsRunning(actions.SyncSSHKeys)) + ctx.SetData("sshManagesAuthorizedKeys", config.C.SshManagesAuthorizedKeys()) return ctx.Html("admin_index.html") } @@ -102,6 +105,7 @@ func AdminUserDelete(ctx *context.Context) error { if err := user.Delete(); err != nil { return ctx.ErrorRes(500, "Cannot delete this user", err) } + opengistssh.SyncAuthorizedKeysLogged() ctx.AddFlash(ctx.Tr("flash.admin.user-deleted"), "success") return ctx.RedirectTo("/admin-panel/users") diff --git a/internal/web/handlers/api/v1/gist_list_test.go b/internal/web/handlers/api/v1/gist_list_test.go index e462655..ad56854 100644 --- a/internal/web/handlers/api/v1/gist_list_test.go +++ b/internal/web/handlers/api/v1/gist_list_test.go @@ -63,9 +63,8 @@ func idSet(arr []types.GistSimple) map[string]bool { } // TestListGists_GistObjectShape verifies every field of a types.GistSimple coming -// back from /api/gists is populated as expected. HttpGit and SshGit are -// toggled on so the URL-bearing fields aren't empty; the test restores config -// on cleanup. +// back from /api/gists is populated as expected. HTTP git and the SSH server are +// enabled so the URL-bearing fields aren't empty. func TestListGists_GistObjectShape(t *testing.T) { s := webtest.Setup(t) t.Cleanup(func() { webtest.Teardown(t) }) @@ -74,7 +73,7 @@ func TestListGists_GistObjectShape(t *testing.T) { s.Register(t, "thomas") config.C.HttpGit = true - config.C.SshGit = true + config.C.SshGit = config.SshServerBuiltin config.C.SshExternalDomain = "gist.example.com" config.C.SshPort = "22" diff --git a/internal/web/handlers/api/v1/gist_test.go b/internal/web/handlers/api/v1/gist_test.go index d8fc8e2..bf56ed2 100644 --- a/internal/web/handlers/api/v1/gist_test.go +++ b/internal/web/handlers/api/v1/gist_test.go @@ -133,7 +133,7 @@ func TestGetGist_ResponseShape(t *testing.T) { config.C.SshExternalDomain, config.C.SshPort = prevDomain, prevPort }) config.C.HttpGit = true - config.C.SshGit = true + config.C.SshGit = config.SshServerBuiltin config.C.SshExternalDomain = "gist.example.com" config.C.SshPort = "22" diff --git a/internal/web/handlers/ipc/hook.go b/internal/web/handlers/ipc/hook.go new file mode 100644 index 0000000..0c55c8e --- /dev/null +++ b/internal/web/handlers/ipc/hook.go @@ -0,0 +1,53 @@ +// Package ipc implements the daemon side of Opengist's internal API: +// endpoints called by short-lived subprocesses (Git hooks, the SSH shim) so the +// DB and index work happens in the long-running server with its warm +// connection pool, instead of being opened fresh on every invocation. +// +// These routes are mounted under /api/ipc and protected by a token middleware; +// they are not part of the public site or the v1 API. +package ipc + +import ( + "net/http" + + "github.com/thomiceli/opengist/internal/db" + "github.com/thomiceli/opengist/internal/git" + "github.com/thomiceli/opengist/internal/hooks" + "github.com/thomiceli/opengist/internal/ipc" + "github.com/thomiceli/opengist/internal/web/context" +) + +// PreReceive handles a pre-receive event forwarded by the hook subprocess and +// returns whether the push may proceed. +func PreReceive(ctx *context.Context) error { + var req ipc.HookPreReceiveRequest + if err := ctx.Bind(&req); err != nil { + return ctx.String(http.StatusBadRequest, "invalid request") + } + + allowed, message := hooks.RunPreReceive(req.ChangedFiles) + + return ctx.JSON(http.StatusOK, ipc.HookPreReceiveResponse{Allowed: allowed, Message: message}) +} + +// PostReceive handles a post-receive event forwarded by the hook subprocess. +func PostReceive(ctx *context.Context) error { + var req ipc.HookPostReceiveRequest + if err := ctx.Bind(&req); err != nil { + return ctx.String(http.StatusBadRequest, "invalid request") + } + + gist, err := db.GetGistByID(req.GistID) + if err != nil { + return ctx.String(http.StatusInternalServerError, "failed to get gist") + } + + repoDir := git.RepositoryPath(gist.User.Username, gist.Uuid) + + output, err := hooks.RunPostReceive(gist, repoDir, req.GistURL, req.References, req.PushOptions) + if err != nil { + return ctx.String(http.StatusInternalServerError, err.Error()) + } + + return ctx.JSON(http.StatusOK, ipc.HookPostReceiveResponse{Output: output}) +} diff --git a/internal/web/handlers/ipc/ssh.go b/internal/web/handlers/ipc/ssh.go new file mode 100644 index 0000000..d9e9f4f --- /dev/null +++ b/internal/web/handlers/ipc/ssh.go @@ -0,0 +1,61 @@ +package ipc + +import ( + "errors" + "net/http" + "strconv" + + "github.com/thomiceli/opengist/internal/db" + "github.com/thomiceli/opengist/internal/git" + "github.com/thomiceli/opengist/internal/ipc" + "github.com/thomiceli/opengist/internal/ssh" + "github.com/thomiceli/opengist/internal/web/context" + "gorm.io/gorm" +) + +// SSHKeys resolves a public key offered to sshd (forwarded by the `keys` +// command) to its stored SSH key id, so the AuthorizedKeysCommand can emit a +// forced command that identifies the connecting key. +func SSHKeys(ctx *context.Context) error { + var req ipc.SSHKeyLookupRequest + if err := ctx.Bind(&req); err != nil { + return ctx.String(http.StatusBadRequest, "invalid request") + } + + key, err := db.GetSSHKeyByContent(req.Key) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusOK, ipc.SSHKeyLookupResponse{Found: false}) + } + return ctx.String(http.StatusInternalServerError, "lookup failed") + } + + return ctx.JSON(http.StatusOK, ipc.SSHKeyLookupResponse{Found: true, KeyID: key.ID}) +} + +// SSHCommand authorizes a git command for a connecting SSH key (forwarded by the +// `shell` forced command) and tells it which git pack command to run, and where. +// The git data plane stays in the shim; only this control plane runs here. +func SSHCommand(ctx *context.Context) error { + var req ipc.SSHCommandRequest + if err := ctx.Bind(&req); err != nil { + return ctx.String(http.StatusBadRequest, "invalid request") + } + + sshKey, err := db.GetSSHKeyByID(req.KeyID) + if err != nil { + return ctx.JSON(http.StatusOK, ipc.SSHCommandResponse{Authorized: false, Message: "key not recognized"}) + } + + gist, verb, err := ssh.AuthorizeGitCommand(req.Command, sshKey.Content, req.IP) + if err != nil { + return ctx.JSON(http.StatusOK, ipc.SSHCommandResponse{Authorized: false, Message: err.Error()}) + } + + return ctx.JSON(http.StatusOK, ipc.SSHCommandResponse{ + Authorized: true, + Verb: verb, + RepoPath: git.RepositoryPath(gist.User.Username, gist.Uuid), + GistID: strconv.FormatUint(uint64(gist.ID), 10), + }) +} diff --git a/internal/web/handlers/settings/account.go b/internal/web/handlers/settings/account.go index 3876a53..148db3f 100644 --- a/internal/web/handlers/settings/account.go +++ b/internal/web/handlers/settings/account.go @@ -12,6 +12,7 @@ import ( "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/i18n" + opengistssh "github.com/thomiceli/opengist/internal/ssh" "github.com/thomiceli/opengist/internal/validator" "github.com/thomiceli/opengist/internal/web/context" ) @@ -45,6 +46,7 @@ func AccountDeleteProcess(ctx *context.Context) error { if err := user.Delete(); err != nil { return ctx.ErrorRes(500, "Cannot delete this user", err) } + opengistssh.SyncAuthorizedKeysLogged() return ctx.RedirectTo("/all") } diff --git a/internal/web/handlers/settings/sshkey.go b/internal/web/handlers/settings/sshkey.go index fd0eee7..5fd22df 100644 --- a/internal/web/handlers/settings/sshkey.go +++ b/internal/web/handlers/settings/sshkey.go @@ -3,6 +3,7 @@ package settings import ( "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/i18n" + opengistssh "github.com/thomiceli/opengist/internal/ssh" "github.com/thomiceli/opengist/internal/validator" "github.com/thomiceli/opengist/internal/web/context" "golang.org/x/crypto/ssh" @@ -44,6 +45,7 @@ func SshKeysProcess(ctx *context.Context) error { if err := key.Create(); err != nil { return ctx.ErrorRes(500, "Cannot add SSH key", err) } + opengistssh.SyncAuthorizedKeysLogged() ctx.AddFlash(ctx.Tr("flash.user.ssh-key-added"), "success") return ctx.RedirectTo("/settings/ssh") @@ -65,6 +67,7 @@ func SshKeysDelete(ctx *context.Context) error { if err := key.Delete(); err != nil { return ctx.ErrorRes(500, "Cannot delete SSH key", err) } + opengistssh.SyncAuthorizedKeysLogged() ctx.AddFlash(ctx.Tr("flash.user.ssh-key-deleted"), "success") return ctx.RedirectTo("/settings/ssh") diff --git a/internal/web/server/middlewares.go b/internal/web/server/middlewares.go index 591c365..f3de826 100644 --- a/internal/web/server/middlewares.go +++ b/internal/web/server/middlewares.go @@ -1,6 +1,7 @@ package server import ( + "crypto/subtle" "errors" "fmt" "html/template" @@ -19,6 +20,7 @@ import ( "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/i18n" + "github.com/thomiceli/opengist/internal/ipc" "github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/handlers" "golang.org/x/text/cases" @@ -69,7 +71,8 @@ func (s *Server) registerMiddlewares() { CookieHTTPOnly: true, CookieSameSite: http.SameSiteStrictMode, Skipper: func(ctx echo.Context) bool { - // skip CSRF for /api (uses bearer tokens, not session cookies) + // skip CSRF for /api (uses bearer tokens, not session cookies); this + // also covers the token-authenticated IPC API under /api/ipc if strings.HasPrefix(ctx.Request().URL.Path, "/api/") { return true } @@ -590,3 +593,16 @@ func apiRequireAuth(next Handler) Handler { return next(ctx) } } + +// ipcAuth guards the IPC API: only callers presenting the token derived from the +// secret key (i.e. Opengist's own subprocesses) may proceed. +func ipcAuth(next Handler) Handler { + return func(ctx *context.Context) error { + got := []byte(ctx.Request().Header.Get(ipc.AuthHeader)) + want := []byte(ipc.Token()) + if subtle.ConstantTimeCompare(got, want) != 1 { + return ctx.NoContent(http.StatusUnauthorized) + } + return next(ctx) + } +} diff --git a/internal/web/server/router.go b/internal/web/server/router.go index 8c4ccfb..9b850a0 100644 --- a/internal/web/server/router.go +++ b/internal/web/server/router.go @@ -20,6 +20,7 @@ import ( "github.com/thomiceli/opengist/internal/web/handlers/gist" "github.com/thomiceli/opengist/internal/web/handlers/git" "github.com/thomiceli/opengist/internal/web/handlers/health" + "github.com/thomiceli/opengist/internal/web/handlers/ipc" "github.com/thomiceli/opengist/internal/web/handlers/settings" "github.com/thomiceli/opengist/public" ) @@ -102,6 +103,7 @@ func (s *Server) registerRoutes() { sB.POST("/index-gists", admin.AdminIndexGists) sB.POST("/sync-languages", admin.AdminSyncGistLanguages) sB.POST("/delete-expired-gists", admin.AdminDeleteExpiredGists) + sB.POST("/sync-ssh-keys", admin.AdminSyncSSHKeys) sB.GET("/configuration", admin.AdminConfig) sB.PUT("/set-config", admin.AdminSetConfig) } @@ -158,6 +160,13 @@ func (s *Server) registerRoutes() { apiV1.Any("", noRouteFoundApi) } r.GET("/api/openapi.yaml", api.OpenAPISpec) + + ipcGroup := r.SubGroup("/api/ipc", ipcAuth) + ipcGroup.POST("/hook/pre-receive", ipc.PreReceive) + ipcGroup.POST("/hook/post-receive", ipc.PostReceive) + ipcGroup.POST("/ssh/keys", ipc.SSHKeys) + ipcGroup.POST("/ssh/command", ipc.SSHCommand) + r.Any("/api/*", noRouteFoundApi) r.GET("/all", gist.AllGists, checkRequireLogin, setAllGistsMode("all")) diff --git a/templates/pages/admin_index.html b/templates/pages/admin_index.html index 568563a..c62e218 100644 --- a/templates/pages/admin_index.html +++ b/templates/pages/admin_index.html @@ -104,6 +104,14 @@ {{ .locale.Tr "admin.actions.delete-expired-gists" }} + {{ if .sshManagesAuthorizedKeys }} +
+ {{ .csrfHtml }} + +
+ {{ end }}