carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "log"
6 "slices"
7 "strings"
8
9 "github.com/carlosframework/platform/internal/domains"
10 "github.com/carlosframework/platform/internal/provision"
11)
12
13// directAppsDomainHost reports whether host is one label under the apps
14// domain on a deployment that reserves platform domains. Those names get the
15// delegated-alias limits: no wildcard, no catchall, no first route. Without
16// ReservePlatformDomains the apps domain is not reserved, so the answer is
17// false and the names are ordinary custom domains.
18func (s *Server) directAppsDomainHost(host string) bool {
19 if !s.cfg.ReservePlatformDomains {
20 return false
21 }
22 domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain))
23 if domain == "" || !strings.HasSuffix(host, "."+domain) {
24 return false
25 }
26 label := strings.TrimSuffix(host, "."+domain)
27 return label != "" && !strings.Contains(label, ".")
28}
29
30// hostPolicy is the /domains doors' answer about a hostname, decided from
31// the name and the deployment's config alone, before any claim is read. It
32// can therefore never say anything about who holds a claim.
33type hostPolicy int
34
35const (
36 // hostOpen is an ordinary hostname: any member may attach it.
37 hostOpen hostPolicy = iota
38 // hostPlatform is in the deployment's own zones (platformDomains). Only
39 // a platform operator may attach it, through the --platform door, and
40 // only a platform operator may change or release a claim on it (#209).
41 hostPlatform
42 // hostReserved is refused to everyone, operators included
43 // (reservedDomains, and the console's own hostnames by exact name).
44 hostReserved
45)
46
47func (p hostPolicy) String() string {
48 switch p {
49 case hostOpen:
50 return "open"
51 case hostPlatform:
52 return "platform"
53 case hostReserved:
54 return "reserved"
55 }
56 return "unknown"
57}
58
59// domainHostPolicy classifies an attach of host by account. Every /domains
60// door (the CLI API door, the console form's attachDomain, and the paths and
61// detach doors) asks this one question, so they cannot drift. The console's
62// own hostnames are reserved by exact name on every deployment
63// (consoleHosts); reservedDomains and platformDomains cover whole subtrees,
64// and reserved wins where the two overlap (a tunnel host under a
65// CONSOLE_DENY_DOMAINS entry stays reserved).
66func (s *Server) domainHostPolicy(host, account string) hostPolicy {
67 if slices.Contains(s.consoleHosts(), host) {
68 return hostReserved
69 }
70 // provision.SidecarSuffix names a sidecar process, never a hostname
71 // anyone serves. attachDomain refuses it with its own message; this
72 // covers the API door's alias branch, which does not reach that check,
73 // and keeps a deny entry naming the suffix from turning it into a
74 // platform name the operator's door would lift (Astra review, #209).
75 if strings.HasSuffix(host, provision.SidecarSuffix) {
76 return hostReserved
77 }
78 // Another live account's <sqid>.<apps-domain> namespace (#215): reserved
79 // to everyone, the operator's door included, and whatever
80 // ReservePlatformDomains says.
81 if s.otherAccountsNamespace(host, account) {
82 return hostReserved
83 }
84 allowed := domains.ValidSqid(account) && slices.Contains(s.cfg.AppsDomainAliasAccounts, account) && s.directAppsDomainHost(host)
85 if allowed {
86 domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain))
87 label := strings.TrimSuffix(host, "."+domain)
88 if domains.ValidSqid(label) {
89 return hostReserved
90 }
91 }
92 if domains.HostDenied(host, s.reservedDomains(allowed)) {
93 return hostReserved
94 }
95 if domains.HostDenied(host, s.platformDomains(allowed)) {
96 return hostPlatform
97 }
98 return hostOpen
99}
100
101// domainHostDenied reports whether a MEMBER's attach of host by account is
102// refused as platform-managed.
103func (s *Server) domainHostDenied(host, account string) bool {
104 return s.domainHostPolicy(host, account) != hostOpen
105}
106
107// platformOperator reports whether addr may use the platform door (#209):
108// attach a hostname in the deployment's own zones, and change or release a
109// claim on one. It is apiSystemRoller's rule, in its order: an owner of the
110// ops account, or an address on SystemOperators (CARLOS_SYSTEM_OPERATORS).
111// The owner check comes first and a store error is not the end, so a listed
112// operator still gets in on a console whose role lookup failed, and a store
113// error is never what admits anyone.
114//
115// It grants no reach into an account: every door that asks it has already
116// established that addr may act on the account and app in question.
117func (s *Server) platformOperator(addr string) bool {
118 if addr == "" {
119 return false
120 }
121 role, err := s.store.Role(OpsAccountID, addr)
122 if err != nil {
123 log.Printf("console: platform operator: role: %v", err)
124 }
125 if err == nil && role == RoleOwner {
126 return true
127 }
128 return s.systemOperator(addr)
129}
130
131// principalPlatformOperator is platformOperator for an API caller: only a
132// person can be one. A service credential acts for an app, never for the
133// deployment.
134func (s *Server) principalPlatformOperator(p Principal) bool {
135 return p.Kind == PrincipalUser && s.platformOperator(p.Address)
136}
137
138// platformOperatorMsg is the refusal for a caller who asks for the platform
139// door, or writes to a platform hostname's claim, without being an operator.
140const platformOperatorMsg = "that hostname is platform-managed. only a platform operator (an ops account owner, or an address on CARLOS_SYSTEM_OPERATORS) can attach or change it"
141
142// appsNamespace returns the label of the account namespace host falls in —
143// "bes" for bes.<apps-domain> and for anything under it, such as
144// web.bes.<apps-domain> — or "" when host is not under the apps domain or the
145// label could not be an account (domains.ValidSqid). Shape only: whether an
146// account with that sqid exists is the caller's question.
147func (s *Server) appsNamespace(host string) string {
148 domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain))
149 if domain == "" {
150 return ""
151 }
152 rest, ok := strings.CutSuffix(host, "."+domain)
153 if !ok || rest == "" {
154 return ""
155 }
156 label := rest[strings.LastIndex(rest, ".")+1:]
157 if !domains.ValidSqid(label) {
158 return ""
159 }
160 return label
161}
162
163// otherAccountsNamespace reports whether host is in ANOTHER account's
164// namespace (#215): <sqid>.<apps-domain> or anything under it, where sqid
165// names an account (live or deleted) that is not account.
166//
167// Refused on every deployment, with or without ReservePlatformDomains. That
168// setting decides whether the apps domain is the operator's to hand out,
169// which is a real choice on a self-hosted box; it was never meant to decide
170// whether one tenant may take another's addresses. Without this, an account
171// could attach new.<their-sqid>.<apps-domain>, which serves at once through
172// the DNS wildcard and their account wildcard certificate, and then stop
173// them creating an app of that name (claimApp's apexHolder check).
174//
175// Only labels some account has held. A sqid-shaped label nobody has held
176// stays an ordinary name, as it always was on a deployment that does not reserve the apps
177// domain (www and app are both sqid-shaped). What such a name cannot do is
178// become a first route, because a first route under the apps domain would
179// make the edge order a wildcard for its label (firstRouteOutsideNamespace).
180//
181// A lookup failure refuses: the cost of a false refusal is a retry, and the
182// cost of a false admission is another tenant's address.
183func (s *Server) otherAccountsNamespace(host, account string) bool {
184 label := s.appsNamespace(host)
185 if label == "" || label == account {
186 return false
187 }
188 if s.store == nil {
189 return true // no account table to ask: refuse, as on a lookup failure
190 }
191 // AccountAnyBySqid, not AccountBySqid: a deleted account's namespace
192 // stays reserved too. Its tombstone keeps the sqid from ever being
193 // reissued, and a name claimed under it would keep serving through the
194 // apps-domain wildcard DNS while looking like that account's address.
195 a, _, err := s.store.AccountAnyBySqid(label)
196 if err != nil {
197 log.Printf("console: domains: account lookup for %s: %v — refusing", host, err)
198 return true
199 }
200 return a != nil
201}
202
203// firstRouteOutsideNamespace reports whether a FIRST-ROUTE attach of host by
204// account would mint a route in a namespace under the apps domain that is not
205// the account's own: two or more labels under it, with a sqid-shaped label
206// that is not this account's sqid. Such a route is Provisioned, and the edge
207// derives an account wildcard parent, <label>.<apps-domain>, from every
208// Provisioned route two labels under the apps domain (edge.parentsFor), so
209// each invented label would order a wildcard certificate against the
210// deployment's shared ACME budget. otherAccountsNamespace already refuses a
211// live account's label; this covers the labels no account holds YET.
212//
213// Labels that could never be a sqid are left alone here: parentsFor derives
214// no parent from them, so a first route under one costs one ordinary
215// per-host certificate, as any custom domain does.
216func (s *Server) firstRouteOutsideNamespace(host, account string) bool {
217 domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain))
218 if domain == "" {
219 return false
220 }
221 rest, ok := strings.CutSuffix(host, "."+domain)
222 if !ok || !strings.Contains(rest, ".") {
223 return false
224 }
225 label := s.appsNamespace(host)
226 return label != "" && label != account
227}
228
229// domainProofExempt reports whether account's claim on host needs no TXT
230// proof, because the platform, not a customer zone, answers for the name:
231// the account's own namespace under the apps domain, and the one-label names
232// an operator delegated to it (CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS). Neither
233// has a zone the member could publish a record in, and both were already
234// settled as this account's by the deployment's own config.
235func (s *Server) domainProofExempt(host, account string) bool {
236 if s.appsNamespace(host) == account && account != "" {
237 return true
238 }
239 // A platform hostname (#209) is in the deployment's own zones: only a
240 // platform operator gets past the door to attach one, and its zone is
241 // the operator's, so the operator's say-so is the proof.
242 if s.domainHostPolicy(host, account) == hostPlatform {
243 return true
244 }
245 return domains.ValidSqid(account) && slices.Contains(s.cfg.AppsDomainAliasAccounts, account) && s.directAppsDomainHost(host)
246}
247
248// challengeDeployment is the deployment name ownership tokens are bound to
249// (domains.ChallengeToken): the console's own hostname.
250func (s *Server) challengeDeployment() string {
251 if hosts := s.consoleHosts(); len(hosts) > 0 {
252 return hosts[0]
253 }
254 return ""
255}
256
257// domainChallenge is the TXT record that proves account controls host.
258func (s *Server) domainChallenge(host, account string) dnsRecord {
259 return dnsRecord{
260 Type: "TXT", Name: domains.ChallengeName(host),
261 Value: domains.ChallengeToken(s.challengeDeployment(), account, host),
262 TTL: domainRecordTTL, Set: "verify", Required: true,
263 }
264}
265
266// domainProver returns the proof AttachClaimProven asks for: true when the
267// name is proof-exempt, otherwise whether ChallengeName(host) carries this
268// account's token right now. Any lookup failure is "not proven" — the claim
269// is then written pending and the member retries, which is the safe
270// direction for a check whose whole job is to refuse the unproven.
271//
272// With DomainProof off (the default) every attach is proven by definition,
273// so claims are written verified and the resolver is never asked: the
274// behaviour this door had before #215.
275func (s *Server) domainProver(ctx context.Context, host, account string) func() bool {
276 return func() bool {
277 if !s.cfg.DomainProof || s.domainProofExempt(host, account) {
278 return true
279 }
280 want := domains.ChallengeToken(s.challengeDeployment(), account, host)
281 got, err := s.lookupTXT(ctx, domains.ChallengeName(host))
282 if err != nil {
283 return false
284 }
285 for _, v := range got {
286 if strings.TrimSpace(v) == want {
287 return true
288 }
289 }
290 return false
291 }
292}
293