1package config
2
3import (
4 "fmt"
5 "net"
6 "net/url"
7 "path/filepath"
8 "regexp"
9 "slices"
10 "strconv"
11 "strings"
12 "time"
13
14 "tug/internal/platform/userbasekeys"
15)
16
17var (
18 groupPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`)
19 clientIDPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,31}$`)
20 labelPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
21 numericPattern = regexp.MustCompile(`^[0-9]+$`)
22 portPattern = regexp.MustCompile(`^[1-9][0-9]{0,4}$`)
23)
24
25const sshPrefix = "ssh://"
26
27var unsafeRedirectSchemes = map[string]bool{"javascript": true, "data": true, "vbscript": true, "file": true}
28
29type validator struct {
30 file string
31 idx *keyIndex
32 problems []Problem
33}
34
35func (v *validator) lineFor(key string) int {
36 for k := key; k != ""; {
37 if line := v.idx.line(k); line > 0 {
38 return line
39 }
40 i := strings.LastIndexAny(k, ".[")
41 if i < 0 {
42 break
43 }
44 k = k[:i]
45 }
46 return 0
47}
48
49func (v *validator) add(key, reason string) {
50 v.problems = append(v.problems, Problem{File: v.file, Line: v.lineFor(key), Key: key, Reason: reason})
51}
52
53func (v *validator) required(key, value string) bool {
54 if !v.idx.has(key) {
55 v.add(key, reasonRequired)
56 return false
57 }
58 if value == "" {
59 v.add(key, reasonEmpty)
60 return false
61 }
62 return true
63}
64
65func (v *validator) duration(key, value string, def time.Duration) time.Duration {
66 if !v.idx.has(key) {
67 return def
68 }
69 d, err := time.ParseDuration(value)
70 if err != nil {
71 v.add(key, reasonDuration)
72 return 0
73 }
74 if d <= 0 {
75 v.add(key, reasonDurationPositive)
76 return 0
77 }
78 return d
79}
80
81func (v *validator) requiredDuration(key, value string) time.Duration {
82 if !v.idx.has(key) {
83 v.add(key, reasonRequired)
84 return 0
85 }
86 return v.duration(key, value, 0)
87}
88
89func (v *validator) group(key, value string) {
90 if !groupPattern.MatchString(value) {
91 v.add(key, reasonGroupName)
92 }
93}
94
95func (v *validator) baseDN(key, value string) {
96 for _, rdn := range strings.Split(value, ",") {
97 attr, label, ok := strings.Cut(rdn, "=")
98 if !ok || (attr != "dc" && attr != "o" && attr != "ou") || !labelPattern.MatchString(label) {
99 v.add(key, reasonBaseDN)
100 return
101 }
102 }
103}
104
105func (v *validator) absolutePath(key, value string) {
106 if !filepath.IsAbs(value) || filepath.Clean(value) != value || value == "/" {
107 v.add(key, reasonAbsolutePath)
108 }
109}
110
111func (v *validator) hostname(key, value string) {
112 if len(value) > 253 || strings.HasSuffix(value, ".") {
113 v.add(key, reasonHostname)
114 return
115 }
116 labels := strings.Split(value, ".")
117 if len(labels) < 2 || net.ParseIP(value) != nil || numericPattern.MatchString(labels[len(labels)-1]) {
118 v.add(key, reasonHostname)
119 return
120 }
121 for _, l := range labels {
122 if !labelPattern.MatchString(l) {
123 v.add(key, reasonHostname)
124 return
125 }
126 }
127}
128
129func (v *validator) address(key, value string) (net.IP, string) {
130 host, portText, err := net.SplitHostPort(value)
131 if err != nil {
132 v.add(key, reasonHostPort)
133 return nil, ""
134 }
135 ip := net.ParseIP(host)
136 if ip == nil {
137 v.add(key, reasonHostPort)
138 return nil, ""
139 }
140 port, err := strconv.Atoi(portText)
141 if !portPattern.MatchString(portText) || err != nil || port > 65535 {
142 v.add(key, reasonPort)
143 return nil, ""
144 }
145 return ip, net.JoinHostPort(ip.String(), strconv.Itoa(port))
146}
147
148type keyed struct {
149 key, value string
150}
151
152func (v *validator) app(prefix string, a rawApp, sessionTTL time.Duration) App {
153 if v.required(prefix+".domain", a.Domain) {
154 v.hostname(prefix+".domain", a.Domain)
155 }
156 seen := map[string]bool{}
157 for i, g := range a.Groups {
158 key := fmt.Sprintf("%s.groups[%d]", prefix, i)
159 v.group(key, g)
160 if seen[g] {
161 v.add(key, reasonGroupDuplicate)
162 }
163 seen[g] = true
164 }
165 return App{Domain: a.Domain, SessionTTL: v.duration(prefix+".session_ttl", a.SessionTTL, sessionTTL), Groups: append([]string(nil), a.Groups...)}
166}
167
168func (v *validator) distinct(items []keyed, reason string) {
169 seen := map[string]string{}
170 for _, it := range items {
171 if it.value == "" {
172 continue
173 }
174 if first, ok := seen[it.value]; ok {
175 if v.idx.has(it.key) || !v.idx.has(first) {
176 v.add(it.key, reason)
177 } else {
178 v.add(first, reason)
179 }
180 continue
181 }
182 seen[it.value] = it.key
183 }
184}
185
186func usesSSH(remotes ...string) bool {
187 for _, r := range remotes {
188 if strings.HasPrefix(r, sshPrefix) {
189 return true
190 }
191 }
192 return false
193}
194
195func (v *validator) remote(key, value string) bool {
196 if strings.HasPrefix(value, "/") {
197 if !filepath.IsAbs(value) || filepath.Clean(value) != value || value == "/" {
198 v.add(key, reasonRemote)
199 return false
200 }
201 return true
202 }
203 u, err := url.Parse(value)
204 ok := err == nil && strings.HasPrefix(value, sshPrefix) && u.Opaque == "" && u.Hostname() != "" &&
205 u.Path != "" && u.Path != "/" && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && !strings.ContainsAny(value, "?#")
206 if ok {
207 _, hasPassword := u.User.Password()
208 ok = u.User.Username() != "" && !hasPassword
209 }
210 if ok && u.Port() != "" {
211 n, err := strconv.Atoi(u.Port())
212 ok = portPattern.MatchString(u.Port()) && err == nil && n <= 65535
213 }
214 if !ok {
215 v.add(key, reasonRemote)
216 }
217 return ok
218}
219
220func normalizeRemote(remote string) string {
221 remote = strings.TrimSuffix(remote, "/")
222 u, err := url.Parse(remote)
223 if err != nil || u.Scheme != "ssh" || u.Host == "" {
224 return remote
225 }
226 host := strings.ToLower(u.Hostname())
227 if port := u.Port(); port != "" && port != "22" {
228 host = net.JoinHostPort(host, port)
229 }
230 u.Host = host
231 return u.String()
232}
233
234func redirectURIValid(uri string) bool {
235 u, err := url.Parse(uri)
236 switch {
237 case err != nil, u.Scheme == "", u.Fragment != "", strings.Contains(uri, "#"), unsafeRedirectSchemes[strings.ToLower(u.Scheme)]:
238 return false
239 case u.Opaque != "":
240 return strings.EqualFold(u.Scheme, "urn")
241 case u.Scheme == "http" || u.Scheme == "https":
242 if u.Hostname() == "" {
243 return false
244 }
245 if p := u.Port(); p != "" {
246 n, err := strconv.Atoi(p)
247 return portPattern.MatchString(p) && err == nil && n <= 65535
248 }
249 }
250 return true
251}
252
253func validate(file string, idx *keyIndex, raw *rawFile) (*Config, []Problem) {
254 v := &validator{file: file, idx: idx}
255 cfg := &Config{}
256 var listeners []keyed
257
258 s := raw.Server
259 socketSet, listenSet := idx.has("server.socket"), idx.has("server.listen")
260 switch {
261 case socketSet && listenSet:
262 v.add("server.listen", reasonSocketXorListen)
263 case !socketSet && !listenSet:
264 v.add("server.socket", reasonSocketXorListen)
265 }
266 if socketSet {
267 v.absolutePath("server.socket", s.Socket)
268 cfg.Server.Socket = s.Socket
269 }
270 if listenSet {
271 if ip, addr := v.address("server.listen", s.Listen); ip != nil {
272 if !ip.IsLoopback() {
273 v.add("server.listen", reasonLoopbackOnly)
274 }
275 listeners = append(listeners, keyed{"server.listen", addr})
276 cfg.Server.Listen = s.Listen
277 }
278 }
279 metrics := defaultMetricsListen
280 if idx.has("server.metrics_listen") {
281 metrics = s.MetricsListen
282 }
283 if ip, addr := v.address("server.metrics_listen", metrics); ip != nil {
284 switch {
285 case ip.IsUnspecified():
286 v.add("server.metrics_listen", reasonUnspecifiedAddress)
287 case ip.IsMulticast():
288 v.add("server.metrics_listen", reasonMulticastAddress)
289 }
290 listeners = append(listeners, keyed{"server.metrics_listen", addr})
291 }
292 cfg.Server.MetricsListen = metrics
293 domains := []keyed{
294 {"server.domain_idp", s.DomainIDP},
295 {"server.domain_account", s.DomainAccount},
296 {"server.domain_admin", s.DomainAdmin},
297 }
298 for _, d := range domains {
299 if v.required(d.key, d.value) {
300 v.hostname(d.key, d.value)
301 }
302 }
303 cfg.Server.DomainIDP, cfg.Server.DomainAccount, cfg.Server.DomainAdmin = s.DomainIDP, s.DomainAccount, s.DomainAdmin
304 cfg.Server.AdminGroup = defaultAdminGroup
305 if idx.has("server.admin_group") {
306 v.group("server.admin_group", s.AdminGroup)
307 cfg.Server.AdminGroup = s.AdminGroup
308 }
309 cfg.Server.SiteName = v.siteName(s.SiteName)
310
311 if idx.has("portdistrict") {
312 pd := raw.Portdistrict
313 if pd == nil {
314 pd = &rawPortdistrict{}
315 }
316 out := &Portdistrict{Listen: pd.Listen, PeerTable: pd.PeerTable}
317 if v.required("portdistrict.listen", pd.Listen) {
318 if ip, addr := v.address("portdistrict.listen", pd.Listen); ip != nil {
319 switch {
320 case ip.IsUnspecified():
321 v.add("portdistrict.listen", reasonUnspecifiedAddress)
322 case ip.IsLoopback():
323 v.add("portdistrict.listen", reasonLoopbackAddress)
324 case ip.IsMulticast():
325 v.add("portdistrict.listen", reasonMulticastAddress)
326 }
327 listeners = append(listeners, keyed{"portdistrict.listen", addr})
328 }
329 }
330 if v.required("portdistrict.peer_table", pd.PeerTable) {
331 v.absolutePath("portdistrict.peer_table", pd.PeerTable)
332 }
333 out.TableMaxAge = v.requiredDuration("portdistrict.table_max_age", pd.TableMaxAge)
334 cfg.Portdistrict = out
335 }
336
337 cfg.Source = sourceFile
338 if idx.has("config.source") {
339 switch raw.Config.Source {
340 case sourceFile, sourceGit:
341 default:
342 v.add("config.source", reasonSourceValue)
343 }
344 cfg.Source = raw.Config.Source
345 }
346 var repos []keyed
347 if idx.has("config.git") {
348 cfg.ConfigGit = v.configGit(raw.Config.Git)
349 repos = append(repos, keyed{"config.git.remote", normalizeRemote(cfg.ConfigGit.Remote)})
350 } else if cfg.Source == sourceGit {
351 v.add("config.git", reasonRequired)
352 }
353 if idx.has("userbase") {
354 u := raw.Userbase
355 if u == nil {
356 u = &rawUserbase{}
357 }
358 remotesValid := v.required("userbase.repo", u.Repo) && v.remote("userbase.repo", u.Repo)
359 if idx.has("userbase.mirror") {
360 if u.Mirror == "" {
361 v.add("userbase.mirror", reasonEmpty)
362 } else {
363 remotesValid = v.remote("userbase.mirror", u.Mirror) && remotesValid
364 }
365 }
366 switch {
367 case !remotesValid:
368 case idx.has("userbase.known_hosts") && !usesSSH(u.Repo, u.Mirror):
369 v.add("userbase.known_hosts", reasonKnownHostsUnused)
370 case idx.has("userbase.known_hosts"):
371 v.absolutePath("userbase.known_hosts", u.KnownHosts)
372 case usesSSH(u.Repo, u.Mirror):
373 v.add("userbase.known_hosts", reasonRequired)
374 }
375 if v.required("userbase.break_glass_recipient", u.BreakGlassRecipient) {
376 if _, err := userbasekeys.ParseRecipient(u.BreakGlassRecipient); err != nil {
377 v.add("userbase.break_glass_recipient", reasonAgeRecipient)
378 }
379 }
380 repos = append(repos, keyed{"userbase.repo", normalizeRemote(u.Repo)}, keyed{"userbase.mirror", normalizeRemote(u.Mirror)})
381 cfg.Userbase = &Userbase{Repo: u.Repo, Mirror: u.Mirror, KnownHosts: u.KnownHosts, BreakGlassRecipient: u.BreakGlassRecipient, ExportSigningKeys: u.ExportSigningKeys, RestoreOnFirstBoot: u.RestoreOnFirstBoot}
382 }
383 v.distinct(repos, reasonReposDistinct)
384
385 cfg.Invite.TTL = v.duration("invite.ttl", raw.Invite.TTL, defaultInviteTTL)
386 cfg.Invite.DefaultGroups = append([]string(nil), defaultInviteGroups...)
387 if idx.has("invite.default_groups") {
388 cfg.Invite.DefaultGroups = append([]string{}, raw.Invite.DefaultGroups...)
389 seen := map[string]bool{}
390 for i, g := range raw.Invite.DefaultGroups {
391 key := fmt.Sprintf("invite.default_groups[%d]", i)
392 v.group(key, g)
393 if seen[g] {
394 v.add(key, reasonGroupDuplicate)
395 }
396 seen[g] = true
397 }
398 }
399 cfg.Session.TTL = v.duration("session.ttl", raw.Session.TTL, defaultSessionTTL)
400 cfg.Audit = v.audit(raw.Audit)
401
402 if idx.has("google") {
403 g := raw.Google
404 if g == nil {
405 g = &rawGoogle{}
406 }
407 v.required("google.client_id", g.ClientID)
408 cfg.Google = &Google{ClientID: g.ClientID, AllowedHD: g.AllowedHD}
409 }
410
411 cfg.PrimaryGroup = defaultPrimaryGroup
412 if idx.has("ldap") {
413 l := raw.LDAP
414 if l == nil {
415 l = &rawLDAP{}
416 }
417 if v.required("ldap.listen", l.Listen) {
418 if ip, addr := v.address("ldap.listen", l.Listen); ip != nil {
419 switch {
420 case ip.IsUnspecified():
421 v.add("ldap.listen", reasonUnspecifiedAddress)
422 case ip.IsMulticast():
423 v.add("ldap.listen", reasonMulticastAddress)
424 }
425 listeners = append(listeners, keyed{"ldap.listen", addr})
426 }
427 }
428 if idx.has("ldap.primary_group") {
429 v.group("ldap.primary_group", l.PrimaryGroup)
430 cfg.PrimaryGroup = l.PrimaryGroup
431 }
432 if v.required("ldap.base_dn", l.BaseDN) {
433 v.baseDN("ldap.base_dn", l.BaseDN)
434 }
435 certSet, keySet := idx.has("ldap.tls_cert"), idx.has("ldap.tls_key")
436 switch {
437 case certSet && !keySet:
438 v.add("ldap.tls_key", reasonTLSPair)
439 case keySet && !certSet:
440 v.add("ldap.tls_cert", reasonTLSPair)
441 }
442 if certSet {
443 v.absolutePath("ldap.tls_cert", l.TLSCert)
444 }
445 if keySet {
446 v.absolutePath("ldap.tls_key", l.TLSKey)
447 }
448 cfg.LDAP = &LDAP{Listen: l.Listen, BaseDN: l.BaseDN, TLSCert: l.TLSCert, TLSKey: l.TLSKey}
449 }
450 if cfg.Server.AdminGroup == cfg.PrimaryGroup {
451 if !idx.has("server.admin_group") && idx.has("ldap.primary_group") {
452 v.add("ldap.primary_group", reasonAdminIsPrimary)
453 } else {
454 v.add("server.admin_group", reasonAdminIsPrimary)
455 }
456 } else if i := slices.Index(cfg.Invite.DefaultGroups, cfg.Server.AdminGroup); i >= 0 {
457 if idx.has("invite.default_groups") {
458 v.add(fmt.Sprintf("invite.default_groups[%d]", i), reasonAdminIsDefaultGroup)
459 } else {
460 v.add("server.admin_group", reasonAdminIsDefaultGroup)
461 }
462 }
463
464 ids := map[string]bool{}
465 for i, c := range raw.Client {
466 prefix := fmt.Sprintf("client[%d]", i)
467 if v.required(prefix+".id", c.ID) {
468 if !clientIDPattern.MatchString(c.ID) {
469 v.add(prefix+".id", reasonClientID)
470 } else if ids[c.ID] {
471 v.add(prefix+".id", reasonClientIDDuplicate)
472 }
473 ids[c.ID] = true
474 }
475 if !c.Public && len(c.RedirectURIs) == 0 {
476 v.add(prefix+".redirect_uris", reasonRedirectRequired)
477 }
478 uris := map[string]bool{}
479 for j, uri := range c.RedirectURIs {
480 key := fmt.Sprintf("%s.redirect_uris[%d]", prefix, j)
481 if !redirectURIValid(uri) {
482 v.add(key, reasonRedirectURI)
483 }
484 if uris[uri] {
485 v.add(key, reasonRedirectDuplicate)
486 }
487 uris[uri] = true
488 }
489 cfg.Clients = append(cfg.Clients, Client{ID: c.ID, RedirectURIs: append([]string(nil), c.RedirectURIs...), Public: c.Public})
490 }
491
492 for i, a := range raw.App {
493 prefix := fmt.Sprintf("app[%d]", i)
494 domains = append(domains, keyed{prefix + ".domain", a.Domain})
495 cfg.Apps = append(cfg.Apps, v.app(prefix, a, cfg.Session.TTL))
496 }
497 v.distinct(domains, reasonDomainReused)
498 v.distinct(listeners, reasonListenerReused)
499
500 return cfg, v.problems
501}