v3.0.0 f19f453

local_authority.go RawHistoryFolder

1package admin
2
3import (
4	"context"
5	"net/http"
6	"path"
7	"strings"
8
9	"github.com/bnema/gordon/internal/domain"
10)
11
12// LocalAuthoritySubject is the principal carried on requests served over the
13// owner-only admin Unix socket. Filesystem access to the 0600 socket is what
14// establishes this identity; the scopes below still gate each route.
15const LocalAuthoritySubject = "local-owner"
16
17// localAuthorityScopes are the least-privilege scopes required by CLI
18// commands which share the local and remote ControlPlane. The path-and-method
19// allowlist below is the second boundary: config:write, for example, permits
20// reload and prune operations but cannot reach unrelated config mutations.
21var localAuthorityScopes = []string{
22	domain.AdminScopeApps(domain.AdminActionRead, domain.AdminActionWrite),
23	domain.AdminScopeStatus(domain.AdminActionRead),
24	domain.AdminScopeConfig(domain.AdminActionRead, domain.AdminActionWrite),
25	domain.AdminScopeLogs(domain.AdminActionRead),
26	domain.AdminScopeVolumes(domain.AdminActionRead, domain.AdminActionWrite),
27}
28
29// LocalAuthority returns the handler for the owner-only admin Unix socket.
30// Filesystem ownership authenticates the caller; this wrapper then restricts
31// that identity to the canonical endpoints used by local CLI commands.
32func (h *Handler) LocalAuthority() http.Handler {
33	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34		// Classify and dispatch on one canonical path: the socket bypasses the
35		// ServeMux that would otherwise clean "." and ".." segments, so a
36		// non-canonical path must never be allowlisted here and interpreted
37		// differently by the router below.
38		if !strings.HasPrefix(r.URL.Path, "/admin/") && r.URL.Path != "/admin" {
39			sendForbidden(w, "local administration is limited to app endpoints")
40			return
41		}
42		trimmed := strings.TrimPrefix(r.URL.Path, "/admin")
43		cleaned := path.Clean(trimmed)
44		if cleaned != trimmed || !localPathAllowed(r.Method, cleaned) {
45			sendForbidden(w, "endpoint is not available to the local owner")
46			return
47		}
48
49		scopes := make([]string, len(localAuthorityScopes))
50		copy(scopes, localAuthorityScopes)
51
52		ctx := context.WithValue(r.Context(), domain.ContextKeyScopes, scopes)
53		ctx = context.WithValue(ctx, domain.ContextKeySubject, LocalAuthoritySubject)
54		h.handleAdminRoutes(w, r.WithContext(ctx))
55	})
56}
57
58func localPathAllowed(method, cleanedPath string) bool {
59	if localExactPathAllowed(method, cleanedPath) {
60		return true
61	}
62	parts := strings.Split(strings.TrimPrefix(cleanedPath, "/"), "/")
63	prefixRules := map[string]func(string, []string) bool{
64		"apps":    localAppPathAllowed,
65		"backups": localBackupPathAllowed,
66		"logs":    localLogPathAllowed,
67		"tags":    localTagPathAllowed,
68	}
69	rule, ok := prefixRules[parts[0]]
70	return ok && rule(method, parts)
71}
72
73func localLogPathAllowed(method string, parts []string) bool {
74	return (len(parts) == 1 || len(parts) == 3 && parts[1] != "" && parts[2] != "") && method == http.MethodGet
75}
76
77func localTagPathAllowed(method string, parts []string) bool {
78	return len(parts) == 2 && parts[1] != "" && method == http.MethodGet
79}
80
81func localExactPathAllowed(method, cleanedPath string) bool {
82	allowed := map[string]string{
83		"/status":                 http.MethodGet,
84		"/tls/status":             http.MethodGet,
85		"/traffic/status":         http.MethodGet,
86		"/config":                 http.MethodGet,
87		"/networks":               http.MethodGet,
88		"/volumes":                http.MethodGet,
89		"/volumes/prune":          http.MethodPost,
90		"/images":                 http.MethodGet,
91		"/images/prune":           http.MethodPost,
92		"/reload":                 http.MethodPost,
93		"/backups":                http.MethodGet,
94		"/backups/status":         http.MethodGet,
95		"/backups/volumes":        http.MethodGet,
96		"/backups/volumes/status": http.MethodGet,
97	}
98	return allowed[cleanedPath] == method
99}
100
101func localAppPathAllowed(method string, parts []string) bool {
102	appRoutes := map[string]string{
103		"":                              http.MethodGet,
104		"apply":                         http.MethodPost,
105		"{app}":                         http.MethodGet,
106		"{app}/diff":                    http.MethodGet,
107		"{app}/deploy":                  http.MethodPost,
108		"{app}/restart":                 http.MethodPost,
109		"{app}/stop":                    http.MethodPost,
110		"{app}/start":                   http.MethodPost,
111		"{app}/remove":                  http.MethodPost,
112		"{app}/secrets":                 http.MethodGet,
113		"{app}/secrets/set":             http.MethodPost,
114		"{app}/secrets/delete":          http.MethodPost,
115		"{app}/operations/by-key/{key}": http.MethodGet,
116	}
117	return appRoutes[localAppRouteShape(parts)] == method
118}
119
120func localAppRouteShape(parts []string) string {
121	if len(parts) == 1 {
122		return ""
123	}
124	if len(parts) == 2 && parts[1] == "apply" {
125		return "apply"
126	}
127	if len(parts) < 2 || parts[1] == "" {
128		return "invalid"
129	}
130	shaped := append([]string{"{app}"}, parts[2:]...)
131	if len(shaped) == 4 && shaped[1] == "operations" && shaped[2] == "by-key" && shaped[3] != "" {
132		shaped[3] = "{key}"
133	}
134	return strings.Join(shaped, "/")
135}
136
137func localBackupPathAllowed(method string, parts []string) bool {
138	if len(parts) == 2 {
139		if parts[1] == "volumes" {
140			return method == http.MethodPost
141		}
142		return parts[1] != "" && parts[1] != "status" && (method == http.MethodGet || method == http.MethodPost)
143	}
144	if len(parts) == 3 && parts[1] == "volumes" && parts[2] != "" && parts[2] != "status" {
145		return method == http.MethodGet || method == http.MethodPost
146	}
147	return false
148}

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