v3.0.0-alpha.1 6fdddb8

image_policy.go RawHistoryFolder

1package domain
2
3import (
4	"fmt"
5	"net"
6	"strconv"
7	"strings"
8
9	"github.com/bnema/gordon/pkg/validation"
10)
11
12var defaultImageRegistries = []string{"docker.io", "ghcr.io", "quay.io"}
13
14type ImagePullTransport string
15
16const (
17	ImagePullTransportVerifiedTLS ImagePullTransport = "verified-tls"
18	ImagePullTransportHTTP        ImagePullTransport = "http"
19)
20
21type ImagePullRequest struct {
22	Reference string
23	Username  string
24	Password  string
25	Transport ImagePullTransport
26}
27
28// IsInstallationImage reports whether ref belongs to the configured installation registry.
29func (p ImageSourcePolicy) IsInstallationImage(ref string) bool {
30	host, _, err := parseImageRegistry(strings.TrimSpace(ref))
31	if err != nil || p.InstallationRegistry == "" {
32		return false
33	}
34	installation, err := canonicalRegistryHost(p.InstallationRegistry)
35	return err == nil && dockerRegistryAlias(host) == dockerRegistryAlias(installation)
36}
37
38// ImageSourcePolicy is the installation policy for every image reference
39// the daemon validates, resolves, pulls, or runs.
40type ImageSourcePolicy struct {
41	// AllowedRegistries adds explicit hostname+port entries to the defaults.
42	AllowedRegistries []string
43	// RequireDigest rejects mutable tag references.
44	RequireDigest bool
45	// InstallationRegistry is Gordon's own registry host, always allowed.
46	InstallationRegistry string
47}
48
49// Validate checks every configured registry authority.
50func (p ImageSourcePolicy) Validate() error {
51	entries := append([]string{}, p.AllowedRegistries...)
52	if p.InstallationRegistry != "" {
53		entries = append(entries, p.InstallationRegistry)
54	}
55	for _, entry := range entries {
56		if _, err := canonicalRegistryHost(entry); err != nil {
57			return fmt.Errorf("%w: %s", ErrAppImageNotAllowed, err)
58		}
59	}
60	return nil
61}
62
63// ValidateImageSource checks one reference against the installation registry
64// allowlist. The allowlist controls names the runtime may contact; it does not
65// constrain where DNS resolves or provide runtime network egress enforcement.
66func (p ImageSourcePolicy) ValidateImageSource(ref string) error {
67	host, rest, err := parseImageRegistry(strings.TrimSpace(ref))
68	if err != nil {
69		return fmt.Errorf("%w: %s", ErrAppImageNotAllowed, err)
70	}
71	repository, reference := splitRepositoryReference(rest)
72	if err := validation.ValidateRepositoryName(repository); err != nil {
73		return fmt.Errorf("%w: %s", ErrAppImageNotAllowed, err)
74	}
75	if strings.Contains(rest, "@") {
76		if err := validation.ValidateImageDigest(reference); err != nil {
77			return fmt.Errorf("%w: %s", ErrAppImageNotAllowed, err)
78		}
79	} else if p.RequireDigest {
80		return fmt.Errorf("%w: registry policy requires an immutable digest", ErrAppImageNotAllowed)
81	}
82	if p.registryAllowed(host) {
83		return nil
84	}
85	return fmt.Errorf("%w: registry %q is not allowlisted", ErrAppImageNotAllowed, host)
86}
87
88func (p ImageSourcePolicy) registryAllowed(host string) bool {
89	entries := append(append([]string{}, defaultImageRegistries...), p.AllowedRegistries...)
90	if p.InstallationRegistry != "" {
91		entries = append(entries, p.InstallationRegistry)
92	}
93	for _, entry := range entries {
94		allowed, err := canonicalRegistryHost(entry)
95		if err == nil && dockerRegistryAlias(allowed) == dockerRegistryAlias(host) {
96			return true
97		}
98	}
99	return false
100}
101
102func parseImageRegistry(ref string) (string, string, error) {
103	if ref == "" || strings.Contains(ref, "://") || strings.Contains(ref, "\\") {
104		return "", "", fmt.Errorf("malformed image reference")
105	}
106	first, rest, explicit := strings.Cut(ref, "/")
107	if !explicit || (!strings.ContainsAny(first, ".:") && !strings.EqualFold(first, "localhost")) {
108		first, rest = "docker.io", ref
109	}
110	if strings.Contains(first, "@") || rest == "" {
111		return "", "", fmt.Errorf("malformed registry host")
112	}
113	host, err := canonicalRegistryHost(first)
114	if err != nil {
115		return "", "", err
116	}
117	return host, rest, nil
118}
119
120// canonicalRegistryHost safely normalizes a hostname or IP plus optional port.
121// HTTPS's default port is omitted so host and host:443 are equivalent.
122func canonicalRegistryHost(value string) (string, error) {
123	value = strings.TrimSpace(value)
124	if value == "" || strings.ContainsAny(value, "/@?#") {
125		return "", fmt.Errorf("malformed registry host %q", value)
126	}
127	host, port, err := splitRegistryAuthority(value)
128	if err != nil {
129		return "", err
130	}
131	host = strings.TrimSuffix(strings.ToLower(host), ".")
132	if host == "" || strings.Contains(host, "..") || (net.ParseIP(host) == nil && !validRegistryHostname(host)) {
133		return "", fmt.Errorf("malformed registry host %q", value)
134	}
135	if port != "" {
136		n, err := strconv.Atoi(port)
137		if err != nil || n < 1 || n > 65535 || strconv.Itoa(n) != port {
138			return "", fmt.Errorf("malformed registry port %q", port)
139		}
140		if n != 443 {
141			return net.JoinHostPort(host, port), nil
142		}
143	}
144	return host, nil
145}
146
147func splitRegistryAuthority(value string) (string, string, error) {
148	if strings.HasPrefix(value, "[") {
149		if strings.HasSuffix(value, "]") {
150			return strings.TrimSuffix(strings.TrimPrefix(value, "["), "]"), "", nil
151		}
152		host, port, err := net.SplitHostPort(value)
153		if err != nil {
154			return "", "", fmt.Errorf("malformed registry host %q", value)
155		}
156		return host, port, nil
157	}
158	if strings.Count(value, ":") == 1 {
159		host, port, _ := strings.Cut(value, ":")
160		if host == "" || port == "" {
161			return "", "", fmt.Errorf("malformed registry host %q", value)
162		}
163		return host, port, nil
164	}
165	if strings.Contains(value, ":") && net.ParseIP(value) == nil {
166		return "", "", fmt.Errorf("ambiguous registry host %q", value)
167	}
168	return value, "", nil
169}
170
171func validRegistryHostname(host string) bool {
172	for _, label := range strings.Split(host, ".") {
173		if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
174			return false
175		}
176		for _, r := range label {
177			if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' {
178				return false
179			}
180		}
181	}
182	return len(host) <= 253
183}
184
185func dockerRegistryAlias(host string) string {
186	if host == "registry-1.docker.io" {
187		return "docker.io"
188	}
189	return host
190}
191
192func splitRepositoryReference(rest string) (string, string) {
193	if base, digest, ok := strings.Cut(rest, "@"); ok {
194		return base, digest
195	}
196	if idx := strings.LastIndex(rest, ":"); idx >= 0 {
197		return rest[:idx], rest[idx+1:]
198	}
199	return rest, ""
200}
201
202// IsLocalOrPrivateHost reports whether a literal registry host is local or
203// non-public. It does not resolve DNS names and must not be treated as an
204// egress guarantee.
205func IsLocalOrPrivateHost(host string) bool {
206	hostname, err := canonicalRegistryHost(host)
207	if err != nil {
208		return true
209	}
210	if h, _, splitErr := net.SplitHostPort(hostname); splitErr == nil {
211		hostname = h
212	}
213	hostname = strings.Trim(hostname, "[]")
214	if hostname == "localhost" || strings.HasSuffix(hostname, ".localhost") {
215		return true
216	}
217	ip := net.ParseIP(hostname)
218	return ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast())
219}

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