detached b4f4f5a

fix(security): strip GORDON_ env from journalctl and keep TLS cert dir bindable

commit
b4f4f5a45aa467aa32cdb13655b5ccacba53388c
author
brice · · 2026-10-08 21:15 UTC
parent
64d925b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +26 -28
M docs/config/security-hardening.md +4 -4
@@ -174,7 +174,7 @@ Use `strict` for images designed to write only to mounted volumes and run withou
174174
175175 ## Runtime guard
176176
177-Every app container config passes a final guard right before the runtime creates the container. Bind checks also run at deploy preflight and before every restart or recovery of an existing container. A refused config creates or starts no container: the operation fails and its preflight or service step error contains `runtime guard violation`. A replayed deploy whose preflight failed this way returns error code `runtime-guard-violation` (HTTP 400). The guard refuses:
177+Every app container config passes a final guard right before the runtime creates the container. Bind checks also run at deploy preflight and before every restart or recovery of an existing container. A refused config creates or starts no container: the operation fails and its preflight or service step error contains `runtime guard violation`. The guard refuses:
178178
179179 - network mode `host`, `container:<id>`, or `ns:<path>`;
180180 - a volume name containing `/` or `\`, which the runtime would read as a host path;
@@ -183,14 +183,14 @@ Every app container config passes a final guard right before the runtime creates
183183 Protected paths:
184184
185185 - the data directory and the loaded config file;
186-- files and directories set in the config: `server.tls_cert_file`, `server.tls_key_file`, the Gordon log file, `logging.container_logs.dir`, `logging.access_log.file_path`, and the backup `storage_dir` values;
186+- files and directories set in the config: `server.tls_key_file`, the Gordon log file, `logging.container_logs.dir`, `logging.access_log.file_path`, and the backup `storage_dir` values;
187187 - runtime sockets: `/var/run/docker.sock`, `/run/docker.sock`, `/run/podman/podman.sock`, `$XDG_RUNTIME_DIR/docker.sock`, `$XDG_RUNTIME_DIR/podman/podman.sock`, the `unix://` path of `DOCKER_HOST`, and the `server.runtime` socket;
188188 - rootless runtime storage and config: `~/.local/share/containers`, `~/.local/share/docker`, `${XDG_CONFIG_HOME:-~/.config}/containers`, and `~/.docker`;
189189 - the systemd user units: `${XDG_CONFIG_HOME:-~/.config}/systemd/user`;
190190 - Gordon runtime credentials: `$XDG_RUNTIME_DIR/gordon`, `~/.gordon/run`, and `$GORDON_CLOUDFLARE_API_TOKEN_FILE`;
191191 - key stores: `$GNUPGHOME`, `~/.gnupg`, `$PASSWORD_STORE_DIR`, `~/.password-store`, `$SOPS_AGE_KEY_FILE`, and `${XDG_CONFIG_HOME:-~/.config}/sops/age`.
192192
193-A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent.
193+A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent. Container log and backup directories are protected too: a log-shipper or backup app cannot bind them, even read-only.
194194
195195 The only exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy fails before the running generation is replaced or restarted. Point the policy `source` outside every protected path.
196196
@@ -201,7 +201,7 @@ On Linux, `gordon serve` marks itself non-dumpable (`PR_SET_DUMPABLE=0`) as its
201201 Limits:
202202
203203 - Secrets passed as environment variables, such as `GORDON_AUTH_TOKEN_SECRET`, are readable for the short time between process start and that first step. Prefer a secrets backend.
204-- Helper processes (`pass`, `gpg`, `sops`) are dumpable again after `exec` and hold the values they decrypt while they run. Gordon starts them without its `GORDON_*` variables.
204+- Helper processes (`pass`, `gpg`, `sops`, `journalctl`) are dumpable again after `exec`; secret tools hold the values they decrypt while they run. Gordon starts them without its `GORDON_*` variables.
205205 - Root, or a process of the same user that can replace the Gordon binary or its systemd unit, is not stopped.
206206
207207 ## Hardened systemd user unit
@@ -174,7 +174,7 @@ Use `strict` for images designed to write only to mounted volumes and run withou
174 174
175 ## Runtime guard175 ## Runtime guard
176 176
177-Every app container config passes a final guard right before the runtime creates the container. Bind checks also run at deploy preflight and before every restart or recovery of an existing container. A refused config creates or starts no container: the operation fails and its preflight or service step error contains `runtime guard violation`. A replayed deploy whose preflight failed this way returns error code `runtime-guard-violation` (HTTP 400). The guard refuses:177+Every app container config passes a final guard right before the runtime creates the container. Bind checks also run at deploy preflight and before every restart or recovery of an existing container. A refused config creates or starts no container: the operation fails and its preflight or service step error contains `runtime guard violation`. The guard refuses:
178 178
179 - network mode `host`, `container:<id>`, or `ns:<path>`;179 - network mode `host`, `container:<id>`, or `ns:<path>`;
180 - a volume name containing `/` or `\`, which the runtime would read as a host path;180 - a volume name containing `/` or `\`, which the runtime would read as a host path;
@@ -183,14 +183,14 @@ Every app container config passes a final guard right before the runtime creates
183 Protected paths:183 Protected paths:
184 184
185 - the data directory and the loaded config file;185 - the data directory and the loaded config file;
186-- files and directories set in the config: `server.tls_cert_file`, `server.tls_key_file`, the Gordon log file, `logging.container_logs.dir`, `logging.access_log.file_path`, and the backup `storage_dir` values;186+- files and directories set in the config: `server.tls_key_file`, the Gordon log file, `logging.container_logs.dir`, `logging.access_log.file_path`, and the backup `storage_dir` values;
187 - runtime sockets: `/var/run/docker.sock`, `/run/docker.sock`, `/run/podman/podman.sock`, `$XDG_RUNTIME_DIR/docker.sock`, `$XDG_RUNTIME_DIR/podman/podman.sock`, the `unix://` path of `DOCKER_HOST`, and the `server.runtime` socket;187 - runtime sockets: `/var/run/docker.sock`, `/run/docker.sock`, `/run/podman/podman.sock`, `$XDG_RUNTIME_DIR/docker.sock`, `$XDG_RUNTIME_DIR/podman/podman.sock`, the `unix://` path of `DOCKER_HOST`, and the `server.runtime` socket;
188 - rootless runtime storage and config: `~/.local/share/containers`, `~/.local/share/docker`, `${XDG_CONFIG_HOME:-~/.config}/containers`, and `~/.docker`;188 - rootless runtime storage and config: `~/.local/share/containers`, `~/.local/share/docker`, `${XDG_CONFIG_HOME:-~/.config}/containers`, and `~/.docker`;
189 - the systemd user units: `${XDG_CONFIG_HOME:-~/.config}/systemd/user`;189 - the systemd user units: `${XDG_CONFIG_HOME:-~/.config}/systemd/user`;
190 - Gordon runtime credentials: `$XDG_RUNTIME_DIR/gordon`, `~/.gordon/run`, and `$GORDON_CLOUDFLARE_API_TOKEN_FILE`;190 - Gordon runtime credentials: `$XDG_RUNTIME_DIR/gordon`, `~/.gordon/run`, and `$GORDON_CLOUDFLARE_API_TOKEN_FILE`;
191 - key stores: `$GNUPGHOME`, `~/.gnupg`, `$PASSWORD_STORE_DIR`, `~/.password-store`, `$SOPS_AGE_KEY_FILE`, and `${XDG_CONFIG_HOME:-~/.config}/sops/age`.191 - key stores: `$GNUPGHOME`, `~/.gnupg`, `$PASSWORD_STORE_DIR`, `~/.password-store`, `$SOPS_AGE_KEY_FILE`, and `${XDG_CONFIG_HOME:-~/.config}/sops/age`.
192 192
193-A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent.193+A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent. Container log and backup directories are protected too: a log-shipper or backup app cannot bind them, even read-only.
194 194
195 The only exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy fails before the running generation is replaced or restarted. Point the policy `source` outside every protected path.195 The only exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy fails before the running generation is replaced or restarted. Point the policy `source` outside every protected path.
196 196
@@ -201,7 +201,7 @@ On Linux, `gordon serve` marks itself non-dumpable (`PR_SET_DUMPABLE=0`) as its
201 Limits:201 Limits:
202 202
203 - Secrets passed as environment variables, such as `GORDON_AUTH_TOKEN_SECRET`, are readable for the short time between process start and that first step. Prefer a secrets backend.203 - Secrets passed as environment variables, such as `GORDON_AUTH_TOKEN_SECRET`, are readable for the short time between process start and that first step. Prefer a secrets backend.
204-- Helper processes (`pass`, `gpg`, `sops`) are dumpable again after `exec` and hold the values they decrypt while they run. Gordon starts them without its `GORDON_*` variables.204+- Helper processes (`pass`, `gpg`, `sops`, `journalctl`) are dumpable again after `exec`; secret tools hold the values they decrypt while they run. Gordon starts them without its `GORDON_*` variables.
205 - Root, or a process of the same user that can replace the Gordon binary or its systemd unit, is not stopped.205 - Root, or a process of the same user that can replace the Gordon binary or its systemd unit, is not stopped.
206 206
207 ## Hardened systemd user unit207 ## Hardened systemd user unit
M internal/adapters/in/http/admin/handler_apps.go +0 -1
@@ -539,7 +539,6 @@ func isMappedPreflightError(err error, op *domain.AppOperation) bool {
539539 // Split from isMappedPreflightError to keep its complexity within budget.
540540 func isPreflightErrorRest(err error) bool {
541541 return errors.Is(err, domain.ErrRuntimeUnsupported) ||
542- errors.Is(err, domain.ErrRuntimeGuard) ||
543542 errors.Is(err, domain.ErrAppNotFound) ||
544543 errors.Is(err, domain.ErrAppReservationConflict) ||
545544 errors.Is(err, domain.ErrAppImageUnresolvable) ||
@@ -539,7 +539,6 @@ func isMappedPreflightError(err error, op *domain.AppOperation) bool {
539 // Split from isMappedPreflightError to keep its complexity within budget.539 // Split from isMappedPreflightError to keep its complexity within budget.
540 func isPreflightErrorRest(err error) bool {540 func isPreflightErrorRest(err error) bool {
541 return errors.Is(err, domain.ErrRuntimeUnsupported) ||541 return errors.Is(err, domain.ErrRuntimeUnsupported) ||
542- errors.Is(err, domain.ErrRuntimeGuard) ||
543 errors.Is(err, domain.ErrAppNotFound) ||542 errors.Is(err, domain.ErrAppNotFound) ||
544 errors.Is(err, domain.ErrAppReservationConflict) ||543 errors.Is(err, domain.ErrAppReservationConflict) ||
545 errors.Is(err, domain.ErrAppImageUnresolvable) ||544 errors.Is(err, domain.ErrAppImageUnresolvable) ||
M internal/adapters/in/http/admin/handler_apps_test.go +7 -16
@@ -682,24 +682,15 @@ func TestHandler_AppApply_MapsDevicePolicyViolation(t *testing.T) {
682682 assert.Equal(t, "device-policy-violation", envelope.Error)
683683 }
684684
685-// TestHandler_AppDeploy_MapsRuntimeGuardPreflightReplay proves a terminal
686-// replay whose preflight failed on the runtime guard maps to the stable
687-// runtime-guard-violation envelope.
688-func TestHandler_AppDeploy_MapsRuntimeGuardPreflightReplay(t *testing.T) {
689- appSvc := inmocks.NewMockAppService(t)
690- handler := appsTestHandler(t, appSvc)
685+// TestSendAppOpError_MapsRuntimeGuard proves a runtime guard error returned
686+// synchronously maps to the stable runtime-guard-violation envelope. Async
687+// deploys report the guard in their operation journal instead.
688+func TestSendAppOpError_MapsRuntimeGuard(t *testing.T) {
689+ handler := appsTestHandler(t, inmocks.NewMockAppService(t))
690+ rec := httptest.NewRecorder()
691691
692- op := &domain.AppOperation{
693- Op: "op-g", Kind: "deploy", App: "blog", Outcome: domain.AppOutcomeFailed,
694- Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepFailed, Error: "runtime guard violation"}},
695- }
696- appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(
697- op,
698- fmt.Errorf("deployment: app %q service %q: %w: bind %q overlaps a protected Gordon path", "blog", "web", domain.ErrRuntimeGuard, "conf"),
699- ).Once()
692+ handler.sendAppOpError(rec, fmt.Errorf("deployment: app %q service %q: %w: bind %q overlaps a protected Gordon path", "blog", "web", domain.ErrRuntimeGuard, "conf"))
700693
701- rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy",
702- dto.AppDeployRequest{}, "admin:apps:write")
703694 assert.Equal(t, http.StatusBadRequest, rec.Code)
704695 var envelope dto.AppError
705696 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
@@ -682,24 +682,15 @@ func TestHandler_AppApply_MapsDevicePolicyViolation(t *testing.T) {
682 assert.Equal(t, "device-policy-violation", envelope.Error)682 assert.Equal(t, "device-policy-violation", envelope.Error)
683 }683 }
684 684
685-// TestHandler_AppDeploy_MapsRuntimeGuardPreflightReplay proves a terminal685+// TestSendAppOpError_MapsRuntimeGuard proves a runtime guard error returned
686-// replay whose preflight failed on the runtime guard maps to the stable686+// synchronously maps to the stable runtime-guard-violation envelope. Async
687-// runtime-guard-violation envelope.687+// deploys report the guard in their operation journal instead.
688-func TestHandler_AppDeploy_MapsRuntimeGuardPreflightReplay(t *testing.T) {688+func TestSendAppOpError_MapsRuntimeGuard(t *testing.T) {
689- appSvc := inmocks.NewMockAppService(t)689+ handler := appsTestHandler(t, inmocks.NewMockAppService(t))
690- handler := appsTestHandler(t, appSvc)690+ rec := httptest.NewRecorder()
691 691
692- op := &domain.AppOperation{692+ handler.sendAppOpError(rec, fmt.Errorf("deployment: app %q service %q: %w: bind %q overlaps a protected Gordon path", "blog", "web", domain.ErrRuntimeGuard, "conf"))
693- Op: "op-g", Kind: "deploy", App: "blog", Outcome: domain.AppOutcomeFailed,
694- Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepFailed, Error: "runtime guard violation"}},
695- }
696- appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(
697- op,
698- fmt.Errorf("deployment: app %q service %q: %w: bind %q overlaps a protected Gordon path", "blog", "web", domain.ErrRuntimeGuard, "conf"),
699- ).Once()
700 693
701- rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy",
702- dto.AppDeployRequest{}, "admin:apps:write")
703 assert.Equal(t, http.StatusBadRequest, rec.Code)694 assert.Equal(t, http.StatusBadRequest, rec.Code)
704 var envelope dto.AppError695 var envelope dto.AppError
705 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))696 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
M internal/adapters/out/appsecrets/store.go +1 -1
@@ -13,8 +13,8 @@ import (
1313
1414 "github.com/bnema/zerowrap"
1515
16- "github.com/bnema/gordon/internal/adapters/out/childenv"
1716 "github.com/bnema/gordon/internal/domain"
17+ "github.com/bnema/gordon/pkg/childenv"
1818 )
1919
2020 // Store writes app secret values through the pass CLI.
@@ -13,8 +13,8 @@ import (
13 13
14 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
15 15
16- "github.com/bnema/gordon/internal/adapters/out/childenv"
17 "github.com/bnema/gordon/internal/domain"16 "github.com/bnema/gordon/internal/domain"
17+ "github.com/bnema/gordon/pkg/childenv"
18 )18 )
19 19
20 // Store writes app secret values through the pass CLI.20 // Store writes app secret values through the pass CLI.
M internal/adapters/out/secrets/pass.go +1 -1
@@ -11,8 +11,8 @@ import (
1111
1212 "github.com/bnema/zerowrap"
1313
14- "github.com/bnema/gordon/internal/adapters/out/childenv"
1514 "github.com/bnema/gordon/internal/domain"
15+ "github.com/bnema/gordon/pkg/childenv"
1616 )
1717
1818 // passPathRegex validates pass paths.
@@ -11,8 +11,8 @@ import (
11 11
12 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
13 13
14- "github.com/bnema/gordon/internal/adapters/out/childenv"
15 "github.com/bnema/gordon/internal/domain"14 "github.com/bnema/gordon/internal/domain"
15+ "github.com/bnema/gordon/pkg/childenv"
16 )16 )
17 17
18 // passPathRegex validates pass paths.18 // passPathRegex validates pass paths.
M internal/adapters/out/secrets/sops.go +1 -1
@@ -11,7 +11,7 @@ import (
1111
1212 "github.com/bnema/zerowrap"
1313
14- "github.com/bnema/gordon/internal/adapters/out/childenv"
14+ "github.com/bnema/gordon/pkg/childenv"
1515 )
1616
1717 // sopsKeyPathRegex validates sops key paths (alphanumeric, dots, underscores, hyphens, brackets).
@@ -11,7 +11,7 @@ import (
11 11
12 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
13 13
14- "github.com/bnema/gordon/internal/adapters/out/childenv"14+ "github.com/bnema/gordon/pkg/childenv"
15 )15 )
16 16
17 // sopsKeyPathRegex validates sops key paths (alphanumeric, dots, underscores, hyphens, brackets).17 // sopsKeyPathRegex validates sops key paths (alphanumeric, dots, underscores, hyphens, brackets).
M internal/adapters/out/tokenstore/pass.go +1 -1
@@ -14,8 +14,8 @@ import (
1414
1515 "github.com/bnema/zerowrap"
1616
17- "github.com/bnema/gordon/internal/adapters/out/childenv"
1817 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/pkg/childenv"
1919 )
2020
2121 // ansiRegex matches ANSI escape sequences for stripping from pass output.
@@ -14,8 +14,8 @@ import (
14 14
15 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
16 16
17- "github.com/bnema/gordon/internal/adapters/out/childenv"
18 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/pkg/childenv"
19 )19 )
20 20
21 // ansiRegex matches ANSI escape sequences for stripping from pass output.21 // ansiRegex matches ANSI escape sequences for stripping from pass output.
M internal/app/runtime_guard.go +3 -2
@@ -102,11 +102,12 @@ func homePath(home string, elems ...string) string {
102102 }
103103
104104 // configuredGordonPaths returns the config file, the runtime socket in use,
105-// and every file or directory the config points Gordon at.
105+// and every private file or directory the config points Gordon at. The
106+// public TLS certificate is left out so its directory (often a shared CA
107+// bundle) stays bindable.
106108 func configuredGordonPaths(si *serviceInit) []string {
107109 cfg := si.cfg
108110 paths := []string{
109- cfg.Server.TLSCertFile,
110111 cfg.Server.TLSKeyFile,
111112 resolveLogFilePath(cfg),
112113 cfg.Logging.ContainerLogs.Dir,
@@ -102,11 +102,12 @@ func homePath(home string, elems ...string) string {
102 }102 }
103 103
104 // configuredGordonPaths returns the config file, the runtime socket in use,104 // configuredGordonPaths returns the config file, the runtime socket in use,
105-// and every file or directory the config points Gordon at.105+// and every private file or directory the config points Gordon at. The
106+// public TLS certificate is left out so its directory (often a shared CA
107+// bundle) stays bindable.
106 func configuredGordonPaths(si *serviceInit) []string {108 func configuredGordonPaths(si *serviceInit) []string {
107 cfg := si.cfg109 cfg := si.cfg
108 paths := []string{110 paths := []string{
109- cfg.Server.TLSCertFile,
110 cfg.Server.TLSKeyFile,111 cfg.Server.TLSKeyFile,
111 resolveLogFilePath(cfg),112 resolveLogFilePath(cfg),
112 cfg.Logging.ContainerLogs.Dir,113 cfg.Logging.ContainerLogs.Dir,
M internal/usecase/logs/service.go +8 -1
@@ -18,6 +18,7 @@ import (
1818
1919 "github.com/bnema/gordon/internal/boundaries/out"
2020 "github.com/bnema/gordon/internal/domain"
21+ "github.com/bnema/gordon/pkg/childenv"
2122 )
2223
2324 // appStateProvider resolves app services from the durable ACTIVE record.
@@ -35,7 +36,13 @@ type Service struct {
3536 appState appStateProvider
3637 }
3738
38-var execCommandContext = exec.CommandContext
39+// execCommandContext starts journalctl without Gordon's GORDON_* variables:
40+// the child is dumpable again after exec, so it must not carry secrets.
41+var execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
42+ cmd := exec.CommandContext(ctx, name, args...) // #nosec G204 -- callers pass "journalctl" with internal args only
43+ cmd.Env = childenv.Env()
44+ return cmd
45+}
3946
4047 // NewService creates a new log service. App state is wired after the store opens.
4148 func NewService(
@@ -18,6 +18,7 @@ import (
18 18
19 "github.com/bnema/gordon/internal/boundaries/out"19 "github.com/bnema/gordon/internal/boundaries/out"
20 "github.com/bnema/gordon/internal/domain"20 "github.com/bnema/gordon/internal/domain"
21+ "github.com/bnema/gordon/pkg/childenv"
21 )22 )
22 23
23 // appStateProvider resolves app services from the durable ACTIVE record.24 // appStateProvider resolves app services from the durable ACTIVE record.
@@ -35,7 +36,13 @@ type Service struct {
35 appState appStateProvider36 appState appStateProvider
36 }37 }
37 38
38-var execCommandContext = exec.CommandContext39+// execCommandContext starts journalctl without Gordon's GORDON_* variables:
40+// the child is dumpable again after exec, so it must not carry secrets.
41+var execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
42+ cmd := exec.CommandContext(ctx, name, args...) // #nosec G204 -- callers pass "journalctl" with internal args only
43+ cmd.Env = childenv.Env()
44+ return cmd
45+}
39 46
40 // NewService creates a new log service. App state is wired after the store opens.47 // NewService creates a new log service. App state is wired after the store opens.
41 func NewService(48 func NewService(
R internal/adapters/out/childenv/childenv.go → pkg/childenv/childenv.go +0 -0 renamed, 100% similar

renamed, 100% similar

R internal/adapters/out/childenv/childenv_test.go → pkg/childenv/childenv_test.go +0 -0 renamed, 100% similar

renamed, 100% similar

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close