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}