Add OpenSSH support (#735)

Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
This commit is contained in:
Thomas
2026-06-27 01:39:02 +07:00
committed by GitHub
parent 2a5a054167
commit ea6e7b7eb4
36 changed files with 1202 additions and 213 deletions
+18 -8
View File
@@ -72,25 +72,35 @@ metrics.host: 0.0.0.0
metrics.port: 6158 metrics.port: 6158
# SSH built-in server configuration # SSH built-in server configuration
# Note: it is not using the SSH daemon from your machine (yet)
# Enable or disable SSH built-in server # Enable or disable and specify which SSH server serves git over SSH (either `builtin`, `host`, or `disabled` (no SSH git access).
# for git operations (clone, pull, push) via SSH (either `true` or `false`). Default: true # Default: builtin
ssh.git-enabled: true ssh.git-enabled: builtin
# Host to bind to. Default: 0.0.0.0 # Host to bind to. Default: 0.0.0.0
# This is for builtin SSH server only.
ssh.host: 0.0.0.0 ssh.host: 0.0.0.0
# Port to bind to. Default: 2222 # Default: 2222
# Note: it cannot be the same port as the SSH daemon if it's currently running # For the builtin server, this is the port it binds to.
# If you want to use the port 22 for the built-in SSH server, # Note: it cannot be the same port as the SSH daemon if it's currently running.
# you can either change the port of the SSH daemon or stop it # 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 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. # 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 # If not set, uses the URL from the request
ssh.external-domain: 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 # OAuth2 configuration
# The callback/redirect URL must be http://opengist.url/oauth/<github|gitlab|gitea|openid-connect>/callback # The callback/redirect URL must be http://opengist.url/oauth/<github|gitlab|gitea|openid-connect>/callback
+1
View File
@@ -90,6 +90,7 @@ const docsSidebar = [
{ {
text: 'Administration', base: '/docs/administration', items: [ text: 'Administration', base: '/docs/administration', items: [
{text: 'Run with systemd', link: '/run-with-systemd'}, {text: 'Run with systemd', link: '/run-with-systemd'},
{text: 'Git over SSH with OpenSSH', link: '/openssh'},
{text: 'Reverse proxy', items: [ {text: 'Reverse proxy', items: [
{text: 'Nginx', link: '/nginx-reverse-proxy'}, {text: 'Nginx', link: '/nginx-reverse-proxy'},
{text: 'Traefik', link: '/traefik-reverse-proxy'}, {text: 'Traefik', link: '/traefik-reverse-proxy'},
+142
View File
@@ -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 <key-id>`. 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.
+53 -51
View File
@@ -4,54 +4,56 @@ aside: false
# Configuration Cheat Sheet # Configuration Cheat Sheet
| YAML Config Key | Environment Variable | Default value | Description | | 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-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`. | | 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. | | 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. | | 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. | | 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. | | 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 | 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.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. | | 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`. | | 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) | | 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) | | 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.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.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`) | | 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`) | | 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). | | 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.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.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. | | 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.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.host | OG_SSH_HOST | `0.0.0.0` | The host on which the SSH server should bind. | | 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.port | OG_SSH_PORT | `2222` | The port on which the SSH server should listen. | | ssh.host | OG_SSH_HOST | `0.0.0.0` | The host on which the SSH server should bind. (`builtin` only) |
| 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.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`). |
| github.client-key | OG_GITHUB_CLIENT_KEY | none | The client key for the GitHub OAuth application. | | 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.secret | OG_GITHUB_SECRET | none | The secret for the GitHub OAuth application. | | 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. |
| gitlab.client-key | OG_GITLAB_CLIENT_KEY | none | The client key for the GitLab OAuth application. | | github.client-key | OG_GITHUB_CLIENT_KEY | none | The client key for the GitHub OAuth application. |
| gitlab.secret | OG_GITLAB_SECRET | none | The secret for the GitLab OAuth application. | | github.secret | OG_GITHUB_SECRET | none | The secret for the GitHub OAuth application. |
| gitlab.url | OG_GITLAB_URL | `https://gitlab.com/` | The URL of the GitLab instance. | | gitlab.client-key | OG_GITLAB_CLIENT_KEY | none | The client key for the GitLab OAuth application. |
| gitlab.name | OG_GITLAB_NAME | `GitLab` | The name of the GitLab instance. It is displayed in the OAuth login button. | | gitlab.secret | OG_GITLAB_SECRET | none | The secret for the GitLab OAuth application. |
| gitea.client-key | OG_GITEA_CLIENT_KEY | none | The client key for the Gitea OAuth application. | | gitlab.url | OG_GITLAB_URL | `https://gitlab.com/` | The URL of the GitLab instance. |
| gitea.secret | OG_GITEA_SECRET | none | The secret for the Gitea OAuth application. | | gitlab.name | OG_GITLAB_NAME | `GitLab` | The name of the GitLab instance. It is displayed in the OAuth login button. |
| gitea.url | OG_GITEA_URL | `https://gitea.com/` | The URL of the Gitea instance. | | gitea.client-key | OG_GITEA_CLIENT_KEY | none | The client key for the Gitea OAuth application. |
| gitea.name | OG_GITEA_NAME | `Gitea` | The name of the Gitea instance. It is displayed in the OAuth login button. | | gitea.secret | OG_GITEA_SECRET | none | The secret for the Gitea OAuth application. |
| oidc.provider-name | OG_OIDC_PROVIDER_NAME | none | The name of the OIDC provider | | gitea.url | OG_GITEA_URL | `https://gitea.com/` | The URL of the Gitea instance. |
| oidc.client-key | OG_OIDC_CLIENT_KEY | none | The client key for the OpenID application. | | gitea.name | OG_GITEA_NAME | `Gitea` | The name of the Gitea instance. It is displayed in the OAuth login button. |
| oidc.secret | OG_OIDC_SECRET | none | The secret for the OpenID application. | | oidc.provider-name | OG_OIDC_PROVIDER_NAME | none | The name of the OIDC provider |
| oidc.discovery-url | OG_OIDC_DISCOVERY_URL | none | Discovery endpoint of the OpenID provider. | | oidc.client-key | OG_OIDC_CLIENT_KEY | none | The client key for the OpenID application. |
| oidc.group-claim-name | OG_OIDC_GROUP_CLAIM_NAME | none | Name of the claim containing the groups. | | oidc.secret | OG_OIDC_SECRET | none | The secret for the OpenID application. |
| oidc.admin-group | OG_OIDC_ADMIN_GROUP | none | Name of the group that should receive admin rights. | | oidc.discovery-url | OG_OIDC_DISCOVERY_URL | none | Discovery endpoint of the OpenID provider. |
| ldap.url | OG_LDAP_URL | none | URL of the LDAP instance; if not set, LDAP authentication is disabled | | oidc.group-claim-name | OG_OIDC_GROUP_CLAIM_NAME | none | Name of the claim containing the groups. |
| 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 | | oidc.admin-group | OG_OIDC_ADMIN_GROUP | none | Name of the group that should receive admin rights. |
| ldap.bind-credentials | OG_LDAP_BIND_CREDENTIALS | none | The password for the Bind DN. | | ldap.url | OG_LDAP_URL | none | URL of the LDAP instance; if not set, LDAP authentication is disabled |
| ldap.search-base | OG_LDAP_SEARCH_BASE | none | The Base DN to start search from. e.g: ou=People,dc=example,dc=com | | 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.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) | | ldap.bind-credentials | OG_LDAP_BIND_CREDENTIALS | none | The password for the Bind DN. |
| custom.name | OG_CUSTOM_NAME | none | The name of your instance, to be displayed in the tab title | | ldap.search-base | OG_LDAP_SEARCH_BASE | none | The Base DN to start search from. e.g: ou=People,dc=example,dc=com |
| custom.logo | OG_CUSTOM_LOGO | none | Path to an image, relative to $opengist-home/custom. | | 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.favicon | OG_CUSTOM_FAVICON | none | Path to an image, relative to $opengist-home/custom. | | custom.name | OG_CUSTOM_NAME | none | The name of your instance, to be displayed in the tab title |
| custom.static-links | OG_CUSTOM_STATIC_LINK_#_(PATH,NAME) | none | Path and name to custom links, more info [here](custom-links.md). | | 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). |
+13
View File
@@ -13,6 +13,7 @@ import (
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/index" "github.com/thomiceli/opengist/internal/index"
"github.com/thomiceli/opengist/internal/ssh"
) )
const ( const (
@@ -24,6 +25,7 @@ const (
IndexGists IndexGists
SyncGistLanguages SyncGistLanguages
DeleteExpiredGists DeleteExpiredGists
SyncSSHKeys
numActions // keep last — sizes the `running` array numActions // keep last — sizes the `running` array
) )
@@ -50,6 +52,7 @@ var registry = map[int]action{
IndexGists: {run: indexGists}, IndexGists: {run: indexGists},
SyncGistLanguages: {run: syncGistLanguages}, SyncGistLanguages: {run: syncGistLanguages},
DeleteExpiredGists: {run: deleteExpiredGists, spec: "@every 1m"}, DeleteExpiredGists: {run: deleteExpiredGists, spec: "@every 1m"},
SyncSSHKeys: {run: syncSSHKeys, spec: "@every 72h"},
} }
func IsRunning(actionType int) bool { 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() { func deleteExpiredGists() {
gists, err := db.DeleteExpiredGists() gists, err := db.DeleteExpiredGists()
if err != nil { if err != nil {
+16
View File
@@ -2,11 +2,27 @@ package cli
import ( import (
"fmt" "fmt"
"io"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/auth/password" "github.com/thomiceli/opengist/internal/auth/password"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/urfave/cli/v2" "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{ var CmdAdmin = cli.Command{
Name: "admin", Name: "admin",
Usage: "Admin commands", Usage: "Admin commands",
+7 -21
View File
@@ -1,18 +1,16 @@
package cli package cli
import ( import (
"github.com/rs/zerolog/log" "os"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/hooks" "github.com/thomiceli/opengist/internal/hooks"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"io"
"os"
) )
var CmdHook = cli.Command{ var CmdHook = cli.Command{
Name: "hook", Name: "hook",
Usage: "Run Git server hooks, used and should only be called by Opengist itself", Usage: "Run Git server hooks, used and should only be called by Opengist itself",
Hidden: true,
Subcommands: []*cli.Command{ Subcommands: []*cli.Command{
&CmdHookPreReceive, &CmdHookPreReceive,
&CmdHookPostReceive, &CmdHookPostReceive,
@@ -23,7 +21,7 @@ var CmdHookPreReceive = cli.Command{
Name: "pre-receive", Name: "pre-receive",
Usage: "Run Git server pre-receive hook for a repository", Usage: "Run Git server pre-receive hook for a repository",
Action: func(ctx *cli.Context) error { Action: func(ctx *cli.Context) error {
initialize(ctx) subprocessInitClient(ctx)
if err := hooks.PreReceive(os.Stdin, os.Stdout, os.Stderr); err != nil { if err := hooks.PreReceive(os.Stdin, os.Stdout, os.Stderr); err != nil {
os.Exit(1) os.Exit(1)
} }
@@ -35,22 +33,10 @@ var CmdHookPostReceive = cli.Command{
Name: "post-receive", Name: "post-receive",
Usage: "Run Git server post-receive hook for a repository", Usage: "Run Git server post-receive hook for a repository",
Action: func(ctx *cli.Context) error { Action: func(ctx *cli.Context) error {
initialize(ctx) subprocessInitClient(ctx)
if err := hooks.PostReceive(os.Stdin, os.Stdout, os.Stderr); err != nil { if err := hooks.PostReceive(os.Stdin, os.Stdout, os.Stderr); err != nil {
os.Exit(1) os.Exit(1)
} }
return nil 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")
}
}
+65
View File
@@ -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
},
}
+1 -1
View File
@@ -69,7 +69,7 @@ func App() error {
app.Usage = "A self-hosted pastebin powered by Git." app.Usage = "A self-hosted pastebin powered by Git."
app.HelpName = "opengist" 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.DefaultCommand = CmdStart.Name
app.Flags = []cli.Flag{ app.Flags = []cli.Flag{
&ConfigFlag, &ConfigFlag,
+92
View File
@@ -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 <keyID>`). 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
// ("<client ip> <client port> <server ip> <server port>"), for logging.
func sshClientIP() string {
if fields := strings.Fields(os.Getenv("SSH_CONNECTION")); len(fields) > 0 {
return fields[0]
}
return ""
}
+31
View File
@@ -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()
}
+59 -5
View File
@@ -26,6 +26,14 @@ var C *config
var SecretKey []byte 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 // Not using nested structs because the library
// doesn't support dot notation in this case sadly // doesn't support dot notation in this case sadly
type config struct { type config struct {
@@ -58,10 +66,12 @@ type config struct {
UnixSocketPermissions string `yaml:"unix-socket-permissions" env:"OG_UNIX_SOCKET_PERMISSIONS"` UnixSocketPermissions string `yaml:"unix-socket-permissions" env:"OG_UNIX_SOCKET_PERMISSIONS"`
SshGit bool `yaml:"ssh.git-enabled" env:"OG_SSH_GIT_ENABLED"` SshGit string `yaml:"ssh.git-enabled" env:"OG_SSH_GIT_ENABLED"` // builtin | host | disabled (true → builtin, false → disabled)
SshHost string `yaml:"ssh.host" env:"OG_SSH_HOST"` SshAuthorizedKeysFile string `yaml:"ssh.authorized-keys-file" env:"OG_SSH_AUTHORIZED_KEYS_FILE"`
SshPort string `yaml:"ssh.port" env:"OG_SSH_PORT"` SshHost string `yaml:"ssh.host" env:"OG_SSH_HOST"`
SshExternalDomain string `yaml:"ssh.external-domain" env:"OG_SSH_EXTERNAL_DOMAIN"` 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"` GithubClientKey string `yaml:"github.client-key" env:"OG_GITHUB_CLIENT_KEY"`
GithubSecret string `yaml:"github.secret" env:"OG_GITHUB_SECRET"` 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"` 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) { func configWithDefaults() (*config, error) {
c := &config{} c := &config{}
@@ -126,7 +152,7 @@ func configWithDefaults() (*config, error) {
c.UnixSocketPermissions = "0666" c.UnixSocketPermissions = "0666"
c.SshGit = true c.SshGit = SshServerBuiltin
c.SshHost = "0.0.0.0" c.SshHost = "0.0.0.0"
c.SshPort = "2222" c.SshPort = "2222"
@@ -157,6 +183,11 @@ func InitConfig(configPath string, out io.Writer) error {
return err 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 == "" { if c.OpengistHome == "" {
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
if err != nil { if err != nil {
@@ -393,7 +424,30 @@ func loadConfigFromEnv(c *config, out io.Writer) error {
return nil 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 { 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 { if _, err := url.Parse(c.ExternalUrl); err != nil {
return err return err
} }
+8 -4
View File
@@ -840,20 +840,24 @@ func (gist *Gist) HTTPCloneURL(baseURL string) string {
// SSHCloneURL returns the SSH clone URL. `fallbackHost` is the request's Host // SSHCloneURL returns the SSH clone URL. `fallbackHost` is the request's Host
// header (or any host:port-shaped string) used when SshExternalDomain isn't // header (or any host:port-shaped string) used when SshExternalDomain isn't
// configured — only its hostname part is kept. Returns "" when SSH git access // 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 { func (gist *Gist) SSHCloneURL(fallbackHost string) string {
if !config.C.SshGit { if !config.C.SshEnabled() {
return "" return ""
} }
sshDomain := config.C.SshExternalDomain sshDomain := config.C.SshExternalDomain
if sshDomain == "" { if sshDomain == "" {
sshDomain = strings.Split(fallbackHost, ":")[0] sshDomain = strings.Split(fallbackHost, ":")[0]
} }
var user string
if config.C.SshUsername != "" {
user = config.C.SshUsername + "@"
}
path := gist.User.Username + "/" + gist.Identifier() + ".git" path := gist.User.Username + "/" + gist.Identifier() + ".git"
if config.C.SshPort == "22" { 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) { func (gist *Gist) GetLanguagesFromFiles() ([]string, error) {
+18
View File
@@ -48,6 +48,24 @@ func GetSSHKeyByID(sshKeyId uint) (*SSHKey, error) {
return sshKey, err 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) { func SSHKeyDoesExists(sshKeyContent string) (bool, error) {
var count int64 var count int64
err := db.Model(&SSHKey{}). err := db.Model(&SSHKey{}).
+63 -33
View File
@@ -10,41 +10,76 @@ import (
"strings" "strings"
"time" "time"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/ipc"
validatorpkg "github.com/thomiceli/opengist/internal/validator" 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 { func PostReceive(in io.Reader, out, er io.Writer) error {
var outputSb strings.Builder gistID := os.Getenv("OPENGIST_REPOSITORY_ID")
newGist := false if gistID == "" {
opts := pushOptions() // No gist id is set on the SSH push path, which performs the gist update
gistUrl := os.Getenv("OPENGIST_REPOSITORY_URL_INTERNAL") // in-process instead. Nothing for this hook to forward.
validator := validatorpkg.NewValidator() return nil
}
var refs []ipc.HookRefUpdate
scanner := bufio.NewScanner(in) scanner := bufio.NewScanner(in)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() parts := strings.Fields(scanner.Text())
parts := strings.Fields(line)
if len(parts) != 3 { if len(parts) != 3 {
_, _ = fmt.Fprintln(er, "Invalid input") _, _ = fmt.Fprintln(er, "Invalid input")
return fmt.Errorf("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 { resp, err := ipc.HookPostReceive(&ipc.HookPostReceiveRequest{
setSymbolicRef(refname) 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 newGist = true
} }
} }
gist, err := db.GetGistByID(os.Getenv("OPENGIST_REPOSITORY_ID")) if gistUrl == "" {
if err != nil { gistUrl = strings.TrimSuffix(config.C.ExternalUrl, "/") + "/" + gist.User.Username + "/" + gist.Identifier()
_, _ = fmt.Fprintln(er, "Failed to get gist")
return fmt.Errorf("failed to get gist: %w", err)
} }
if slices.Contains([]string{"public", "unlisted", "private"}, opts["visibility"]) { 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 { 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 { } else if hasNoCommits {
if err = gist.Delete(); err != nil { 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() _ = gist.SetLastActiveNow()
err = gist.UpdatePreviewAndCount(true) if err := gist.UpdatePreviewAndCount(true); err != nil {
if err != nil { return "", fmt.Errorf("failed to update gist: %w", err)
_, _ = fmt.Fprintln(er, "Failed to update gist")
return fmt.Errorf("failed to update gist: %w", err)
} }
gist.AddInIndex() 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) fmt.Fprintf(&outputSb, "git remote set-url origin %s\n\n", gistUrl)
} }
outputStr := outputSb.String() return outputSb.String(), nil
if outputStr != "" {
_, _ = fmt.Fprint(out, "\n"+outputStr)
}
return nil
} }
func verifyHEAD() error { func verifyHEAD(dir string) error {
return exec.Command("git", "rev-parse", "--verify", "--quiet", "HEAD").Run() cmd := exec.Command("git", "rev-parse", "--verify", "--quiet", "HEAD")
cmd.Dir = dir
return cmd.Run()
} }
func setSymbolicRef(refname string) { func setSymbolicRef(dir, refname string) {
_ = exec.Command("git", "symbolic-ref", "HEAD", refname).Run() cmd := exec.Command("git", "symbolic-ref", "HEAD", refname)
cmd.Dir = dir
_ = cmd.Run()
} }
+48 -17
View File
@@ -7,12 +7,17 @@ import (
"io" "io"
"os/exec" "os/exec"
"strings" "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 { func PreReceive(in io.Reader, out, er io.Writer) error {
var err error var changedFilesPerRef []string
var disallowedFiles []string
var disallowedCommits []string
scanner := bufio.NewScanner(in) scanner := bufio.NewScanner(in)
for scanner.Scan() { for scanner.Scan() {
@@ -25,20 +30,45 @@ func PreReceive(in io.Reader, out, er io.Writer) error {
oldRev, newRev := parts[0], parts[1] oldRev, newRev := parts[0], parts[1]
var changedFiles string var rev string
if oldRev == BaseHash { if oldRev == BaseHash {
// First commit // First commit
if changedFiles, err = getChangedFiles(newRev); err != nil { rev = newRev
_, _ = fmt.Fprintln(er, "Failed to get changed files")
return err
}
} else { } else {
if changedFiles, err = getChangedFiles(fmt.Sprintf("%s..%s", oldRev, newRev)); err != nil { rev = fmt.Sprintf("%s..%s", oldRev, newRev)
_, _ = fmt.Fprintln(er, "Failed to get changed files")
return err
}
} }
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 var currentCommit string
for _, file := range strings.Fields(changedFiles) { for _, file := range strings.Fields(changedFiles) {
if strings.HasPrefix(file, "/") { if strings.HasPrefix(file, "/") {
@@ -53,15 +83,16 @@ func PreReceive(in io.Reader, out, er io.Writer) error {
} }
if len(disallowedFiles) > 0 { 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 { 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) _, _ = fmt.Fprintln(&sb)
return fmt.Errorf("pushing files in directories is not allowed: %s", disallowedFiles) return false, sb.String()
} }
return nil return true, ""
} }
func getChangedFiles(rev string) (string, error) { func getChangedFiles(rev string) (string, error) {
-54
View File
@@ -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
}
+2
View File
@@ -313,6 +313,7 @@ admin.actions.reset-hooks: Reset Git server hooks for all repositories
admin.actions.index-gists: Rebuild search index admin.actions.index-gists: Rebuild search index
admin.actions.sync-gist-languages: Synchronize all gists languages admin.actions.sync-gist-languages: Synchronize all gists languages
admin.actions.delete-expired-gists: Delete expired gists admin.actions.delete-expired-gists: Delete expired gists
admin.actions.sync-ssh-keys: Regenerate the authorized_keys file
admin.id: ID admin.id: ID
admin.user: User admin.user: User
admin.delete: Delete 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.index-gists: Rebuilding search index...
flash.admin.sync-gist-languages: Syncing Gist languages... flash.admin.sync-gist-languages: Syncing Gist languages...
flash.admin.delete-expired-gists: Deleting expired gists... 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.username-exists: Username already exists
flash.auth.invalid-credentials: Invalid credentials flash.auth.invalid-credentials: Invalid credentials
+59
View File
@@ -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
}
+101
View File
@@ -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
}
+25
View File
@@ -0,0 +1,25 @@
package ipc
// SSHKeyLookupRequest asks the daemon to resolve a public key (in authorized
// keys form, "<type> <base64>") 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
}
+31
View File
@@ -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
}
+151
View File
@@ -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 <keyID>` (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)
}
+29 -11
View File
@@ -10,11 +10,15 @@ import (
"github.com/thomiceli/opengist/internal/auth" "github.com/thomiceli/opengist/internal/auth"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/git"
"golang.org/x/crypto/ssh"
"gorm.io/gorm" "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) verb, args := parseCommand(gitCmd)
if !strings.HasPrefix(verb, "git-") { if !strings.HasPrefix(verb, "git-") {
verb = "" verb = ""
@@ -22,13 +26,13 @@ func runGitCommand(ch ssh.Channel, gitCmd string, key string, ip string) error {
verb = strings.TrimPrefix(verb, "git-") verb = strings.TrimPrefix(verb, "git-")
if verb != "upload-pack" && verb != "receive-pack" { if verb != "upload-pack" && verb != "receive-pack" {
return errors.New("invalid command") return nil, "", errors.New("invalid command")
} }
repoFullName := strings.ToLower(strings.Trim(args, "'")) repoFullName := strings.ToLower(strings.Trim(args, "'"))
repoFields := strings.SplitN(repoFullName, "/", 2) repoFields := strings.SplitN(repoFullName, "/", 2)
if len(repoFields) != 2 { if len(repoFields) != 2 {
return errors.New("invalid gist path") return nil, "", errors.New("invalid gist path")
} }
userName := strings.ToLower(repoFields[0]) 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) gist, err := db.GetGist(userName, gistName)
if err != nil { if err != nil {
return errors.New("gist not found") return nil, "", errors.New("gist not found")
} }
allowUnauthenticated, err := auth.ShouldAllowUnauthenticatedGistAccess(db.AuthInfo{}, true) allowUnauthenticated, err := auth.ShouldAllowUnauthenticatedGistAccess(db.AuthInfo{}, true)
if err != nil { if err != nil {
errorSsh("Failed to get auth info", err) 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 : // 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 err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
log.Warn().Msg("Invalid SSH authentication attempt from " + ip) 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) 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) _ = 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) repositoryPath := git.RepositoryPath(gist.User.Username, gist.Uuid)
cmd := exec.Command("git", verb, repositoryPath) cmd := exec.Command("git", verb, repositoryPath)
@@ -90,10 +108,10 @@ func runGitCommand(ch ssh.Channel, gitCmd string, key string, ip string) error {
// avoid blocking // avoid blocking
go func() { go func() {
_, _ = io.Copy(stdin, ch) _, _ = io.Copy(stdin, in)
}() }()
_, _ = io.Copy(ch, stdout) _, _ = io.Copy(out, stdout)
_, _ = io.Copy(ch, stderr) _, _ = io.Copy(errOut, stderr)
err = cmd.Wait() err = cmd.Wait()
if err != nil { if err != nil {
+2 -2
View File
@@ -19,7 +19,7 @@ import (
) )
func Start() { func Start() {
if !config.C.SshGit { if !config.C.SshBuiltin() {
return return
} }
@@ -110,7 +110,7 @@ func handleConnexion(channels <-chan ssh.NewChannel, key string, ip string) {
payloadCmd = payloadCmd[i:] 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.Stderr().Write([]byte("Opengist: " + err.Error() + "\r\n"))
} }
_, _ = ch.SendRequest("exit-status", false, []byte{0, 0, 0, 0}) _, _ = ch.SendRequest("exit-status", false, []byte{0, 0, 0, 0})
+6
View File
@@ -52,3 +52,9 @@ func AdminDeleteExpiredGists(ctx *context.Context) error {
go actions.RunOnce(actions.DeleteExpiredGists) go actions.RunOnce(actions.DeleteExpiredGists)
return ctx.RedirectTo("/admin-panel") 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")
}
+4
View File
@@ -9,6 +9,7 @@ import (
"github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git" "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/context"
"github.com/thomiceli/opengist/internal/web/handlers" "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("indexGists", actions.IsRunning(actions.IndexGists))
ctx.SetData("syncGistLanguages", actions.IsRunning(actions.SyncGistLanguages)) ctx.SetData("syncGistLanguages", actions.IsRunning(actions.SyncGistLanguages))
ctx.SetData("deleteExpiredGists", actions.IsRunning(actions.DeleteExpiredGists)) 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") return ctx.Html("admin_index.html")
} }
@@ -102,6 +105,7 @@ func AdminUserDelete(ctx *context.Context) error {
if err := user.Delete(); err != nil { if err := user.Delete(); err != nil {
return ctx.ErrorRes(500, "Cannot delete this user", err) return ctx.ErrorRes(500, "Cannot delete this user", err)
} }
opengistssh.SyncAuthorizedKeysLogged()
ctx.AddFlash(ctx.Tr("flash.admin.user-deleted"), "success") ctx.AddFlash(ctx.Tr("flash.admin.user-deleted"), "success")
return ctx.RedirectTo("/admin-panel/users") return ctx.RedirectTo("/admin-panel/users")
@@ -63,9 +63,8 @@ func idSet(arr []types.GistSimple) map[string]bool {
} }
// TestListGists_GistObjectShape verifies every field of a types.GistSimple coming // TestListGists_GistObjectShape verifies every field of a types.GistSimple coming
// back from /api/gists is populated as expected. HttpGit and SshGit are // back from /api/gists is populated as expected. HTTP git and the SSH server are
// toggled on so the URL-bearing fields aren't empty; the test restores config // enabled so the URL-bearing fields aren't empty.
// on cleanup.
func TestListGists_GistObjectShape(t *testing.T) { func TestListGists_GistObjectShape(t *testing.T) {
s := webtest.Setup(t) s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) }) t.Cleanup(func() { webtest.Teardown(t) })
@@ -74,7 +73,7 @@ func TestListGists_GistObjectShape(t *testing.T) {
s.Register(t, "thomas") s.Register(t, "thomas")
config.C.HttpGit = true config.C.HttpGit = true
config.C.SshGit = true config.C.SshGit = config.SshServerBuiltin
config.C.SshExternalDomain = "gist.example.com" config.C.SshExternalDomain = "gist.example.com"
config.C.SshPort = "22" config.C.SshPort = "22"
+1 -1
View File
@@ -133,7 +133,7 @@ func TestGetGist_ResponseShape(t *testing.T) {
config.C.SshExternalDomain, config.C.SshPort = prevDomain, prevPort config.C.SshExternalDomain, config.C.SshPort = prevDomain, prevPort
}) })
config.C.HttpGit = true config.C.HttpGit = true
config.C.SshGit = true config.C.SshGit = config.SshServerBuiltin
config.C.SshExternalDomain = "gist.example.com" config.C.SshExternalDomain = "gist.example.com"
config.C.SshPort = "22" config.C.SshPort = "22"
+53
View File
@@ -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})
}
+61
View File
@@ -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),
})
}
@@ -12,6 +12,7 @@ import (
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/i18n" "github.com/thomiceli/opengist/internal/i18n"
opengistssh "github.com/thomiceli/opengist/internal/ssh"
"github.com/thomiceli/opengist/internal/validator" "github.com/thomiceli/opengist/internal/validator"
"github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/context"
) )
@@ -45,6 +46,7 @@ func AccountDeleteProcess(ctx *context.Context) error {
if err := user.Delete(); err != nil { if err := user.Delete(); err != nil {
return ctx.ErrorRes(500, "Cannot delete this user", err) return ctx.ErrorRes(500, "Cannot delete this user", err)
} }
opengistssh.SyncAuthorizedKeysLogged()
return ctx.RedirectTo("/all") return ctx.RedirectTo("/all")
} }
+3
View File
@@ -3,6 +3,7 @@ package settings
import ( import (
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n" "github.com/thomiceli/opengist/internal/i18n"
opengistssh "github.com/thomiceli/opengist/internal/ssh"
"github.com/thomiceli/opengist/internal/validator" "github.com/thomiceli/opengist/internal/validator"
"github.com/thomiceli/opengist/internal/web/context" "github.com/thomiceli/opengist/internal/web/context"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
@@ -44,6 +45,7 @@ func SshKeysProcess(ctx *context.Context) error {
if err := key.Create(); err != nil { if err := key.Create(); err != nil {
return ctx.ErrorRes(500, "Cannot add SSH key", err) return ctx.ErrorRes(500, "Cannot add SSH key", err)
} }
opengistssh.SyncAuthorizedKeysLogged()
ctx.AddFlash(ctx.Tr("flash.user.ssh-key-added"), "success") ctx.AddFlash(ctx.Tr("flash.user.ssh-key-added"), "success")
return ctx.RedirectTo("/settings/ssh") return ctx.RedirectTo("/settings/ssh")
@@ -65,6 +67,7 @@ func SshKeysDelete(ctx *context.Context) error {
if err := key.Delete(); err != nil { if err := key.Delete(); err != nil {
return ctx.ErrorRes(500, "Cannot delete SSH key", err) return ctx.ErrorRes(500, "Cannot delete SSH key", err)
} }
opengistssh.SyncAuthorizedKeysLogged()
ctx.AddFlash(ctx.Tr("flash.user.ssh-key-deleted"), "success") ctx.AddFlash(ctx.Tr("flash.user.ssh-key-deleted"), "success")
return ctx.RedirectTo("/settings/ssh") return ctx.RedirectTo("/settings/ssh")
+17 -1
View File
@@ -1,6 +1,7 @@
package server package server
import ( import (
"crypto/subtle"
"errors" "errors"
"fmt" "fmt"
"html/template" "html/template"
@@ -19,6 +20,7 @@ import (
"github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n" "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/context"
"github.com/thomiceli/opengist/internal/web/handlers" "github.com/thomiceli/opengist/internal/web/handlers"
"golang.org/x/text/cases" "golang.org/x/text/cases"
@@ -69,7 +71,8 @@ func (s *Server) registerMiddlewares() {
CookieHTTPOnly: true, CookieHTTPOnly: true,
CookieSameSite: http.SameSiteStrictMode, CookieSameSite: http.SameSiteStrictMode,
Skipper: func(ctx echo.Context) bool { 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/") { if strings.HasPrefix(ctx.Request().URL.Path, "/api/") {
return true return true
} }
@@ -590,3 +593,16 @@ func apiRequireAuth(next Handler) Handler {
return next(ctx) 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)
}
}
+9
View File
@@ -20,6 +20,7 @@ import (
"github.com/thomiceli/opengist/internal/web/handlers/gist" "github.com/thomiceli/opengist/internal/web/handlers/gist"
"github.com/thomiceli/opengist/internal/web/handlers/git" "github.com/thomiceli/opengist/internal/web/handlers/git"
"github.com/thomiceli/opengist/internal/web/handlers/health" "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/internal/web/handlers/settings"
"github.com/thomiceli/opengist/public" "github.com/thomiceli/opengist/public"
) )
@@ -102,6 +103,7 @@ func (s *Server) registerRoutes() {
sB.POST("/index-gists", admin.AdminIndexGists) sB.POST("/index-gists", admin.AdminIndexGists)
sB.POST("/sync-languages", admin.AdminSyncGistLanguages) sB.POST("/sync-languages", admin.AdminSyncGistLanguages)
sB.POST("/delete-expired-gists", admin.AdminDeleteExpiredGists) sB.POST("/delete-expired-gists", admin.AdminDeleteExpiredGists)
sB.POST("/sync-ssh-keys", admin.AdminSyncSSHKeys)
sB.GET("/configuration", admin.AdminConfig) sB.GET("/configuration", admin.AdminConfig)
sB.PUT("/set-config", admin.AdminSetConfig) sB.PUT("/set-config", admin.AdminSetConfig)
} }
@@ -158,6 +160,13 @@ func (s *Server) registerRoutes() {
apiV1.Any("", noRouteFoundApi) apiV1.Any("", noRouteFoundApi)
} }
r.GET("/api/openapi.yaml", api.OpenAPISpec) 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.Any("/api/*", noRouteFoundApi)
r.GET("/all", gist.AllGists, checkRequireLogin, setAllGistsMode("all")) r.GET("/all", gist.AllGists, checkRequireLogin, setAllGistsMode("all"))
+8
View File
@@ -104,6 +104,14 @@
{{ .locale.Tr "admin.actions.delete-expired-gists" }} {{ .locale.Tr "admin.actions.delete-expired-gists" }}
</button> </button>
</form> </form>
{{ if .sshManagesAuthorizedKeys }}
<form action="{{ $.c.ExternalUrl }}/admin-panel/sync-ssh-keys" method="POST">
{{ .csrfHtml }}
<button type="submit" {{ if .syncSSHKeys }}disabled="disabled"{{ end }} class="whitespace-nowrap text-slate-700 dark:text-slate-300{{ if .syncSSHKeys }} text-slate-500 cursor-not-allowed {{ end }}rounded border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-2.5 py-2 text-xs font-medium text-gray-700 dark:text-white shadow-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:outline-none focus:ring-1 focus:border-primary-500 focus:ring-primary-500 leading-3">
{{ .locale.Tr "admin.actions.sync-ssh-keys" }}
</button>
</form>
{{ end }}
</div> </div>
</div> </div>
</div> </div>