mirror of
https://github.com/thomiceli/opengist.git
synced 2026-08-07 07:14:49 +00:00
Add OpenSSH support (#735)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
This commit is contained in:
+18
-8
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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'},
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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{}).
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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})
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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,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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
Vendored
+8
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user