✨ (ssh): Advertise separate external SSH port with scp-shorthand URLs
Changes
11 files changed, +241 -16
MODIFY
README.md
+4 -1
@@ -17,8 +17,11 @@
17
17
- Clone/fetch/push over `https://<host>/git/<owner>/<repo>.git`
18
18
- anonymous read on public repositories
19
19
- push and private read authenticate with **personal access tokens** (HTTP Basic password)
20
-- Clone/fetch/push over `ssh://git@<host>:2222/<owner>/<repo>.git`
20
+- Clone/fetch/push over SSH — scp shorthand `git@<host>:<owner>/<repo>.git` on the standard
21
+ port, or `ssh://git@<host>:<port>/<owner>/<repo>.git` when the advertised port isn't 22
21
22
- public-key authentication only; keys managed per user in the UI
23
+ - the server binds an unprivileged port (default 2222); the port shown in URLs is set
24
+ separately via `GITSHARK_SSH_EXTERNAL_PORT` (default 22) to match your host mapping
22
25
- Web UI: an auth-aware header nav (a "Log in" button for visitors; for signed-in users a top-level Following link plus an Account dropdown holding Profile, SSH keys, Access tokens, and Logout — a JS-free `<details>` menu), landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser with self-hosted syntax highlighting (extension-based language detection, falls back to plain text for unknown extensions and binary files; Markdown files render to HTML by default with a Rendered/Code toggle), a rendered README (commonmark-java with GFM tables, XSS-safe) shown below the file list on the repository overview page, commit log (paginated), branches, tags (own dedicated page, separate from branches), one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Every repository sub-page shows a persistent left sidebar with repo identity, a Clone button opening the clone dialog, a pin toggle, and section navigation (Code, Commits, Branches, Tags, Issues, Merge requests, plus Settings for the owner) with per-section counts and active-section highlighting, and the clone panel has copy-to-clipboard buttons for the HTTP and SSH `git clone` commands. Keyboard shortcuts are an optional, progressive enhancement (`?` opens a help overlay, `Escape` closes it, `g h` goes home) — every page works fully without JavaScript
23
26
- Per-repository issues: title, optional description (rendered as Markdown, XSS-safe), per-repo sequential number (`#1`, `#2`, …), an author, and an optional assignee (any local user, set by username; blank clears it); created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New issue" page; title and description can be edited afterwards via an "Edit issue" page. Issue pages are addressed by number (`…/issues/1`); old UUID URLs redirect permanently to the number form
24
27
- Issues move through a fixed lifecycle (Planned → In development → Done); the repo navigation shows the open (Planned + In development) issue count, and Done issues collapse into an "Archive" section on the issues page
MODIFY
docs/README.md
+3 -0
@@ -66,6 +66,9 @@
66
66
67
67
### For maintainers
68
68
69
+- **[SSH transport & clone URLs](maintainers/ssh-transport.md)** — the embedded
70
+ SSH server, the bind-port vs. advertised-external-port split, and how the
71
+ `ssh://` clone/push URLs are constructed.
69
72
- **[Comments and per-commit diffs](maintainers/comments.md)** — how issue and
70
73
merge-request discussion comments and the per-commit diff view reuse the
71
74
existing comment and diff machinery, with their tables and endpoints.
MODIFY
docs/admins/getting-started.md
+26 -8
@@ -24,7 +24,8 @@
24
24
- A **DNS name** pointing at the host (e.g. `gitshark.example.com`). OIDC redirect URIs
25
25
and — if you enable federation — permanent actor IDs are derived from it.
26
26
- An **OIDC provider** with an authorization-code client for git-shark. PKCE is required.
27
-- Ports **80/443** (reverse proxy) and **2222** (SSH git) reachable from your clients.
27
+- Ports **80/443** (reverse proxy) and the **SSH git** port (host **22** by default, mapped
28
+ to container `2222`) reachable from your clients.
28
29
29
30
---
30
31
@@ -201,9 +202,10 @@
201
202
GITSHARK_AVATAR_ROOT: /data/avatars
202
203
GITSHARK_REPO_IMAGE_ROOT: /data/repo-images
203
204
GITSHARK_SSH_HOST_KEY: /data/ssh/host-key
204
- GITSHARK_SSH_PORT: "2222"
205
+ GITSHARK_SSH_PORT: "2222" # bind port INSIDE the container (>1024 → no root needed)
206
+ GITSHARK_SSH_EXTERNAL_PORT: "22" # port shown in clone URLs; MUST match the published host port below
205
207
ports:
206
- - "2222:2222" # SSH git access, published directly
208
+ - "22:2222" # publish SSH git on the standard port 22 (host 22 → container 2222)
207
209
volumes:
208
210
- repos:/data/repositories # bare git repositories
209
211
- avatars:/data/avatars # user profile pictures
@@ -238,7 +240,18 @@
238
240
(plus the database) are mandatory for a stateful deployment — see
239
241
[Persistent data](persistent-data.md) for what each holds and what breaks without it.
240
242
- **HTTP port 8080 is not published** — it's reached through the reverse proxy on the
241
- Compose network (Step 6). Only SSH (2222) is exposed directly.
243
+ Compose network (Step 6). Only SSH is exposed directly (host **22** → container **2222**).
244
+- **Two SSH port knobs, and they are independent.** `GITSHARK_SSH_PORT` is the port the
245
+ embedded server *binds inside the container* — keep it above 1024 (default `2222`) so the
246
+ process never needs root. `GITSHARK_SSH_EXTERNAL_PORT` is *display only*: it is the port
247
+ git-shark writes into the clone/push URLs shown in the UI (default `22`, and `22` is
248
+ omitted from the printed URL for a clean `ssh://git@host/...`). It changes **no** runtime
249
+ behaviour — the server still binds `GITSHARK_SSH_PORT`. **You are responsible for making
250
+ the two agree with your port publishing:** the external port must equal the host port you
251
+ publish. Above we bind `2222`, publish it on host `22`, and advertise `22` — consistent.
252
+ If the host already runs its own sshd on `22`, publish the container on `2222` instead
253
+ (`"2222:2222"`) **and** set `GITSHARK_SSH_EXTERNAL_PORT: "2222"` so the advertised URL
254
+ matches what clients can actually reach.
242
255
- **Single app replica.** git-shark keeps git state on a `ReadWriteOnce`-style filesystem
243
256
volume; do not scale `app` beyond one instance.
244
257
- Flyway migrates the schema automatically at startup (`migrate-at-start=true`), so the
@@ -311,11 +324,15 @@
311
324
**SSH** (public-key only; add your key under *SSH keys* in the UI):
312
325
313
326
```bash
314
-git clone ssh://git@gitshark.example.com:2222/<owner>/<repo>.git
327
+git clone git@gitshark.example.com:<owner>/<repo>.git
315
328
```
316
329
317
-> Want bare `git@gitshark.example.com` without the `:2222`? Publish the container's 2222
318
-> on host port 22 (`"22:2222"`) — but only if the host's own sshd isn't already using 22.
330
+> On the default port `GITSHARK_SSH_EXTERNAL_PORT=22` the UI shows the short scp-like form
331
+> above (`git@host:owner/repo.git`, exactly like GitHub) — matching the `"22:2222"` publish.
332
+> The scp shorthand **cannot carry a port**, so if you publish SSH on a non-standard host
333
+> port (e.g. `"2222:2222"`), set `GITSHARK_SSH_EXTERNAL_PORT` to that port and the UI falls
334
+> back to the explicit form `ssh://git@gitshark.example.com:2222/...`. The external port is
335
+> display-only; it must equal the reachable host port or the copy-paste clone command fails.
319
336
320
337
---
321
338
@@ -338,7 +355,8 @@
338
355
| `GITSHARK_AVATAR_ROOT` | — | `data/avatars` | On-disk profile-picture (avatar) storage root |
339
356
| `GITSHARK_REPO_IMAGE_ROOT` | — | `data/repo-images` | On-disk per-repository image storage root |
340
357
| `GITSHARK_SSH_HOST_KEY` | — | `data/ssh/host-key` | Persistent SSH host key path |
341
-| `GITSHARK_SSH_PORT` | — | `2222` | Embedded SSH server port |
358
+| `GITSHARK_SSH_PORT` | — | `2222` | Port the embedded SSH server **binds inside the container**; keep >1024 so it needs no root |
359
+| `GITSHARK_SSH_EXTERNAL_PORT` | — | `22` | Port advertised in clone/push URLs (display only, no runtime effect). Must match the published host port; `22` is omitted from the printed URL |
342
360
| `GITSHARK_SECRET_KEY` | — | — | Encrypts push-mirror secrets at rest; required to create mirrors (see [Push mirrors](mirrors.md)) |
343
361
| `GITSHARK_MIRROR_MAX_ATTEMPTS` | — | `8` | Mirror-sync retry cap before dead-letter |
344
362
| `GITSHARK_MIRROR_ALLOW_INSECURE` | — | `false` | Dev only: allow http/loopback mirror targets |
ADD
docs/maintainers/ssh-transport.md
+73 -0
@@ -0,0 +1,73 @@
1
+# SSH transport & clone-URL construction
2
+
3
+How git-shark serves Git over SSH, and how the SSH clone/push URLs shown in the
4
+UI are built. For the admin-facing configuration, see
5
+[Getting started → Configuration reference](../admins/getting-started.md#configuration-reference).
6
+
7
+## Component map
8
+
9
+| Component | Responsibility |
10
+|---|---|
11
+| `de.workaround.ssh.GitSshServer` | Starts the embedded Apache SSHD daemon at boot, binds `gitshark.ssh.port`, persists the host key. Public-key auth only; no passwords, no shell. |
12
+| `de.workaround.ssh.GitSshAuthenticator` | Resolves a presented public key to a user via `SshKeyService`. |
13
+| `de.workaround.ssh.GitSshCommandFactory` | Maps the incoming `git-upload-pack` / `git-receive-pack` command to a repository. |
14
+| `de.workaround.web.CloneUrls` | Pure helper that renders the `ssh://` URL string shown in the UI. |
15
+| `de.workaround.web.RepoNavService` | Assembles the `RepoNav` record (clone URLs, counts, …) rendered by the repo sidebar and empty-repo quick start. |
16
+
17
+## Bind port vs. external (advertised) port — the key decision
18
+
19
+There are **two** SSH port config values, and they are deliberately decoupled:
20
+
21
+- `gitshark.ssh.port` (env `GITSHARK_SSH_PORT`, default **2222**) — the port the
22
+ embedded server actually **binds** (`GitSshServer.onStart` → `sshd.setPort(port)`).
23
+ Kept above 1024 by default so the container process never needs `CAP_NET_BIND_SERVICE`
24
+ or root to listen.
25
+- `gitshark.ssh.external-port` (env `GITSHARK_SSH_EXTERNAL_PORT`, default **22**) — the
26
+ port written into the **advertised** clone/push URLs (`RepoNavService` injects it as
27
+ `sshExternalPort` and passes it to `CloneUrls.ssh(...)`). It has **no runtime effect**
28
+ on the server; it exists only so the URL can reflect a host/ingress port mapping.
29
+
30
+**Why the split exists.** The common deployment publishes the container's `2222` on host
31
+port `22` (`"22:2222"` in Compose, or a `Service`/ingress mapping in Kubernetes). Clients
32
+then connect on the clean standard port, but the process inside the container still binds
33
+an unprivileged port. Before this split the UI printed the *bind* port (`2222`), producing
34
+URLs like `ssh://git@host:2222/...` even when SSH was reachable on `22` — wrong and ugly.
35
+Advertising a separate external port fixes both.
36
+
37
+**Invariant the admin must uphold:** `external-port` must equal the host port SSH is
38
+actually reachable on. git-shark cannot verify this — it only renders the string. A
39
+mismatch yields a copy-paste clone command that connects to a closed port. This is called
40
+out in the admin docs; keep both docs consistent if the defaults change.
41
+
42
+## URL rendering rules (`CloneUrls`)
43
+
44
+- `CloneUrls.DEFAULT_SSH_PORT = 22`. When `external-port == 22` it emits the short
45
+ **scp-like** form `git@host:owner/repo.git` (as GitHub does) — no `ssh://`, no port.
46
+ Any other port forces the explicit `ssh://git@host:2222/owner/repo.git` form, because the
47
+ scp shorthand cannot express a port (git would parse the number as part of the path).
48
+- The **host** comes from the incoming web request (`uriInfo.getBaseUri().getHost()`),
49
+ not a separate config value — SSH is assumed to share the web hostname. There is no
50
+ independent SSH-host override today (see gaps below).
51
+- The **HTTP(S)** clone URL is still derived purely from the request base URI
52
+ (`uriInfo.getBaseUri().resolve(...)`), which already normalises away default ports
53
+ (80/443). Only the SSH URL needed the explicit port handling.
54
+
55
+Both URLs land on the `RepoNav` record (`httpUrl`, `sshUrl`) and are rendered by
56
+`templates/RepositoryResource/sidebar.html` (clone dialog) and
57
+`templates/RepositoryResource/overview.html` (empty-repo quick start, HTTPS/SSH toggle).
58
+
59
+## What works today
60
+
61
+- Embedded public-key SSH Git transport on a configurable, unprivileged bind port.
62
+- Separate advertised SSH port so URLs match a host/ingress port mapping; on port 22 the
63
+ URL uses the short scp-like `git@host:owner/repo.git` form.
64
+- Empty-repo quick start offers both HTTPS and SSH push commands via a client-side toggle.
65
+
66
+## What still needs to be implemented
67
+
68
+- **Separate SSH hostname.** The SSH host is hard-tied to the web request host. A
69
+ deployment terminating SSH on a different name (e.g. `ssh.gitshark.example`) cannot
70
+ advertise it — would need a `gitshark.ssh.external-host` config, threaded through
71
+ `CloneUrls` the same way `external-port` is.
72
+- **Runtime validation** that `external-port` is actually reachable — currently purely
73
+ advisory via docs.
ADD
src/main/java/de/workaround/web/CloneUrls.java
+32 -0
@@ -0,0 +1,32 @@
1
+package de.workaround.web;
2
+
3
+/**
4
+ * Builds the clone/remote URLs shown in the UI. Kept separate from the request-derived HTTP URL so the SSH URL can use
5
+ * a configured <em>external</em> port that may differ from the port the embedded server binds inside the container.
6
+ * On the default port (22) it emits the short scp-like form ({@code git@host:owner/repo.git}, as GitHub does); any
7
+ * other port forces the explicit {@code ssh://host:port/...} form, because scp shorthand cannot express a port.
8
+ */
9
+public final class CloneUrls
10
+{
11
+ /** The port git assumes for {@code ssh://} URLs when none is given. */
12
+ public static final int DEFAULT_SSH_PORT = 22;
13
+
14
+ private CloneUrls()
15
+ {
16
+ }
17
+
18
+ /**
19
+ * @param host the externally reachable SSH host (the web request host)
20
+ * @param externalPort the port users connect to (may differ from the container bind port); {@code 22} is omitted
21
+ * @param owner the repository owner handle
22
+ * @param repoName the repository name (without {@code .git})
23
+ */
24
+ public static String ssh(String host, int externalPort, String owner, String repoName)
25
+ {
26
+ if (externalPort == DEFAULT_SSH_PORT)
27
+ {
28
+ return "git@" + host + ":" + owner + "/" + repoName + ".git";
29
+ }
30
+ return "ssh://git@" + host + ":" + externalPort + "/" + owner + "/" + repoName + ".git";
31
+ }
32
+}
MODIFY
src/main/java/de/workaround/web/RepoNavService.java
+5 -4
@@ -45,8 +45,10 @@
45
45
@Inject
46
46
MergeRequestService mergeRequestService;
47
47
48
- @ConfigProperty(name = "gitshark.ssh.port")
49
- int sshPort;
48
+ // The port shown in clone URLs. This is the port users actually connect to (e.g. 22), which may differ from
49
+ // gitshark.ssh.port — the port the embedded server binds inside the container (e.g. 2222, so it needs no root).
50
+ @ConfigProperty(name = "gitshark.ssh.external-port")
51
+ int sshExternalPort;
50
52
51
53
public RepoNav build(Repository repo, UriInfo uriInfo)
52
54
{
@@ -67,8 +69,7 @@
67
69
long openMrCount = mergeRequestService.countOpen(repo);
68
70
String httpUrl = uriInfo.getBaseUri().resolve("/git/" + repo.ownerHandle() + "/" + repo.name + ".git")
69
71
.toString();
70
- String sshUrl = "ssh://git@" + uriInfo.getBaseUri().getHost() + ":" + sshPort + "/" + repo.ownerHandle()
71
- + "/" + repo.name + ".git";
72
+ String sshUrl = CloneUrls.ssh(uriInfo.getBaseUri().getHost(), sshExternalPort, repo.ownerHandle(), repo.name);
72
73
return new RepoNav(repo, loggedIn, isOwner, pinned, empty, defaultBranch, commitCount, branchCount, tagCount,
73
74
openIssueCount, openMrCount, httpUrl, sshUrl, uriInfo.getRequestUri().getRawPath(), parentVisible);
74
75
}
MODIFY
src/main/resources/META-INF/resources/shark.css
+39 -0
@@ -2000,6 +2000,45 @@
2000
2000
display: flex;
2001
2001
}
2002
2002
2003
+/* empty-repo quick start */
2004
+
2005
+.quickstart > input[type="radio"] {
2006
+ position: absolute;
2007
+ opacity: 0;
2008
+ pointer-events: none;
2009
+}
2010
+
2011
+.qs-tabs {
2012
+ display: flex;
2013
+ gap: 6px;
2014
+ margin-bottom: var(--s2);
2015
+}
2016
+
2017
+.qs-tabs label {
2018
+ font: 600 12px/1 var(--mono);
2019
+ color: var(--muted);
2020
+ border: 1px solid var(--border);
2021
+ border-radius: 999px;
2022
+ padding: 7px 16px;
2023
+ cursor: pointer;
2024
+}
2025
+
2026
+#qs-http:checked ~ .qs-tabs label[for="qs-http"],
2027
+#qs-ssh:checked ~ .qs-tabs label[for="qs-ssh"] {
2028
+ color: #fff;
2029
+ background: var(--accent);
2030
+ border-color: var(--accent);
2031
+}
2032
+
2033
+.qs-cmd {
2034
+ display: none;
2035
+}
2036
+
2037
+#qs-http:checked ~ .qs-http-cmd,
2038
+#qs-ssh:checked ~ .qs-ssh-cmd {
2039
+ display: block;
2040
+}
2041
+
2003
2042
/* main column */
2004
2043
2005
2044
.branchbar {
MODIFY
src/main/resources/application.properties
+7 -0
@@ -103,7 +103,14 @@
103
103
%test.gitshark.storage.repo-images=target/test-repo-images
104
104
105
105
# Embedded SSH server
106
+# port the embedded SSH server binds inside the container; keep it >1024 so the process needs no root
106
107
gitshark.ssh.port=${GITSHARK_SSH_PORT:2222}
108
+# port advertised in clone URLs — the externally reachable SSH port (e.g. 22 via a host/ingress port mapping).
109
+# Display only; changing it does NOT change the bind port above. 22 is omitted from the printed URL.
110
+gitshark.ssh.external-port=${GITSHARK_SSH_EXTERNAL_PORT:22}
111
+# In dev the server binds 2222 directly (no host port-mapping), so advertise 2222 too —
112
+# otherwise the UI would print the port-22 scp shorthand while dev SSH listens on 2222.
113
+%dev.gitshark.ssh.external-port=2222
107
114
gitshark.ssh.host-key-path=${GITSHARK_SSH_HOST_KEY:data/ssh/host-key}
108
115
%test.gitshark.ssh.port=0
109
116
%test.gitshark.ssh.host-key-path=target/test-ssh/host-key
MODIFY
src/main/resources/templates/RepositoryResource/overview.html
+11 -1
@@ -9,8 +9,18 @@
9
9
{#if nav.empty}
10
10
<h2>Quick start</h2>
11
11
<p>This repository is empty. Push an existing repository:</p>
12
- <pre>git remote add origin {nav.httpUrl}
12
+ <div class="quickstart">
13
+ <input type="radio" name="qs-proto" id="qs-http" checked>
14
+ <input type="radio" name="qs-proto" id="qs-ssh">
15
+ <div class="qs-tabs">
16
+ <label for="qs-http">HTTPS</label>
17
+ <label for="qs-ssh">SSH</label>
18
+ </div>
19
+ <pre class="qs-cmd qs-http-cmd">git remote add origin {nav.httpUrl}
13
20
git push -u origin main</pre>
21
+ <pre class="qs-cmd qs-ssh-cmd">git remote add origin {nav.sshUrl}
22
+git push -u origin main</pre>
23
+ </div>
14
24
{#else}
15
25
<div class="branchbar">
16
26
<a class="branchsel" href="/repos/{repo.ownerHandle}/{repo.name}/branches"><span class="g">⑂</span> {nav.defaultBranch} <span class="car">▾</span></a>
ADD
src/test/java/de/workaround/web/CloneUrlsTest.java
+24 -0
@@ -0,0 +1,24 @@
1
+package de.workaround.web;
2
+
3
+import org.junit.jupiter.api.Test;
4
+
5
+import static org.junit.jupiter.api.Assertions.assertEquals;
6
+
7
+class CloneUrlsTest
8
+{
9
+ @Test
10
+ void sshUrlUsesScpShorthandForDefaultPort()
11
+ {
12
+ // scp-like shorthand (like GitHub) — shortest form, but git only accepts it on port 22.
13
+ assertEquals("git@shark.example:workaround/gitshark.git",
14
+ CloneUrls.ssh("shark.example", 22, "workaround", "gitshark"));
15
+ }
16
+
17
+ @Test
18
+ void sshUrlKeepsNonDefaultPort()
19
+ {
20
+ // scp shorthand cannot express a port, so a non-default port forces the ssh:// form.
21
+ assertEquals("ssh://git@shark.example:2222/workaround/gitshark.git",
22
+ CloneUrls.ssh("shark.example", 2222, "workaround", "gitshark"));
23
+ }
24
+}
MODIFY
src/test/java/de/workaround/web/WebUiTest.java
+17 -2
@@ -427,7 +427,22 @@
427
427
given().when().get("/repos/" + owner.username + "/cloneurls")
428
428
.then().statusCode(200)
429
429
.body(containsString("/git/" + owner.username + "/cloneurls.git"))
430
- .body(containsString("ssh://git@"));
430
+ .body(containsString("git@localhost:" + owner.username + "/cloneurls.git"));
431
+ }
432
+
433
+ @Test
434
+ void emptyRepositoryQuickStartOffersBothProtocols() throws Exception
435
+ {
436
+ User owner = persistUser("ui-quick-" + unique());
437
+ service.create(owner, "emptyqs", Repository.Visibility.PUBLIC, null);
438
+
439
+ given().when().get("/repos/" + owner.username + "/emptyqs")
440
+ .then().statusCode(200)
441
+ .body(containsString("class=\"quickstart\""))
442
+ .body(containsString("id=\"qs-http\""))
443
+ .body(containsString("id=\"qs-ssh\""))
444
+ .body(containsString("git remote add origin git@localhost:" + owner.username + "/emptyqs.git"))
445
+ .body(containsString("git push -u origin main"));
431
446
}
432
447
433
448
@Test
@@ -521,7 +536,7 @@
521
536
// ...with both protocols selectable...
522
537
.body(containsString("git clone "))
523
538
.body(containsString("/git/" + owner.username + "/clonedialog.git"))
524
- .body(containsString("ssh://git@"))
539
+ .body(containsString("git@localhost:" + owner.username + "/clonedialog.git"))
525
540
// ...and no longer pinned in the header topbar or sidebar
526
541
.body(not(containsString("class=\"clonecmd\"")))
527
542
.body(not(containsString("class=\"clone-urls\"")));