Files
member-console/internal/domains/authorizer.go
T
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

169 lines
6.4 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package domains
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/dnsname"
)
// fallbackTimeout caps the outbound call to the legacy answerer. The ask
// endpoint sits on the TLS handshake path of a proxy that is already
// waiting on us, so a slow legacy answerer must fail closed quickly rather
// than stall certificate issuance.
const fallbackTimeout = 2 * time.Second
// fallbackBodyLimit is how much of the fallback's response body we drain
// before closing, so the connection can be reused. The contract carries no
// body — anything larger is discarded with the connection.
const fallbackBodyLimit = 4 << 10
// RegistryAuthorizer is the registry-backed server.DomainAuthorizer: a name
// is servable iff domains.placements holds a row at exactly that FQDN with
// servable = true. The interface is satisfied structurally — internal/server
// imports internal/domains for Config wiring, so the reverse edge cannot
// exist and this package never names the server interface (design D5, D6).
//
// The lookup is deliberately unconditional (GetPlacementByFQDN ignores
// servability) because the three outcomes are not two:
//
// - servable row → authorize;
// - unservable row → refuse LOCALLY, never consulting the fallback (an
// archived site's name must not be resurrected by a legacy answerer that
// has not caught up);
// - no row at all → a registry miss, the only case the strangler fallback
// may answer.
type RegistryAuthorizer struct {
q Querier
// fallback is the parsed domains-ask-fallback-url; nil disables
// forwarding (the default, and the posture after a malformed URL).
fallback *url.URL
httpClient *http.Client
logger *slog.Logger
}
// NewRegistryAuthorizer builds the authorizer over the registry queries.
// fallbackURL is the optional legacy answerer (viper key
// domains-ask-fallback-url); empty disables forwarding. A malformed URL is
// logged and disables forwarding rather than failing the boot: the fallback
// is a migration-window convenience, and refusing unknown names is the
// safe posture. The configured value is never logged verbatim — see
// redactFallbackURL.
func NewRegistryAuthorizer(q Querier, fallbackURL string, logger *slog.Logger) *RegistryAuthorizer {
if logger == nil {
logger = slog.Default()
}
a := &RegistryAuthorizer{
q: q,
httpClient: &http.Client{Timeout: fallbackTimeout},
logger: logger,
}
if fallbackURL == "" {
return a
}
parsed, err := url.Parse(fallbackURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
// Neither the raw value nor url.Parse's error may be logged: the
// configured URL can carry userinfo credentials
// (https://user:pass@host/ask), and the parse error quotes its input
// verbatim. reason carries the diagnosis the raw value would have.
reason := "missing scheme or host"
if err != nil {
reason = "not a parseable URL"
}
logger.Error("domains-ask-fallback-url is not a usable absolute URL; registry misses will be refused",
slog.String("url", redactFallbackURL(parsed)), slog.String("reason", reason))
return a
}
a.fallback = parsed
return a
}
// redactFallbackURL projects a misconfigured fallback URL onto the only
// field that cannot have absorbed a secret: Host, which url.Parse fills with
// host:port and nothing else. Userinfo, path, and query are dropped rather
// than masked, and so is the scheme — in a value that never split into
// scheme and host, the scheme is where a username lands ("user:pass@host"
// parses as scheme "user"). A value that yielded no host at all (including
// the nil url.Parse returns on failure) has nothing safe left to report.
func redactFallbackURL(u *url.URL) string {
if u == nil || u.Host == "" {
return "(no host)"
}
return u.Host
}
// AuthorizeDomain answers the serving-authorization question for exactly
// fqdn (the caller normalizes; see dnsname.Normalize). It implements the
// structural server.DomainAuthorizer contract.
func (a *RegistryAuthorizer) AuthorizeDomain(ctx context.Context, fqdn string) (bool, error) {
placement, err := a.q.GetPlacementByFQDN(ctx, fqdn)
if err == nil {
// A row decides locally, either way.
return placement.Servable, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return false, err
}
return a.askFallback(ctx, fqdn), nil
}
// askFallback forwards a registry miss to the configured legacy answerer,
// authorizing iff it answers 200. Every other outcome — no fallback, a name
// that is not a well-formed FQDN, a transport error, a timeout, a non-200 —
// refuses. Errors are swallowed rather than returned: a broken legacy
// answerer is a refusal, not a 500 on the ask endpoint.
func (a *RegistryAuthorizer) askFallback(ctx context.Context, fqdn string) bool {
if a.fallback == nil {
return false
}
// Shape-check before any outbound call: the ask route is
// unauthenticated, so this caps the amplification an arbitrary caller
// can aim at the legacy answerer (design D5).
if msg := dnsname.ValidateExternalFQDN(fqdn, nil); msg != "" {
a.logger.Debug("domain ask fallback skipped: name is not a well-formed FQDN",
slog.String("domain", fqdn), slog.String("reason", msg))
return false
}
// Copy the configured URL and set the parameter through url.Values, so
// the attacker-controllable name is escaped by the URL library and any
// query the operator configured on the fallback URL survives.
target := *a.fallback
query := target.Query()
query.Set("domain", fqdn)
target.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
a.logger.Info("domain ask fallback request could not be built",
slog.String("domain", fqdn), slog.Any("error", err))
return false
}
resp, err := a.httpClient.Do(req)
if err != nil {
a.logger.Info("domain ask fallback unreachable; refusing",
slog.String("domain", fqdn), slog.Any("error", err))
return false
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, fallbackBodyLimit))
if resp.StatusCode != http.StatusOK {
a.logger.Debug("domain ask fallback refused",
slog.String("domain", fqdn), slog.Int("status", resp.StatusCode))
return false
}
a.logger.Debug("domain ask fallback authorized a registry miss",
slog.String("domain", fqdn))
return true
}