Raw
  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}