agentbbs/internal/sites/sites.go
RissRIce 4b815eaab8
Some checks are pending
CI / build (push) Waiting to run
deploy / deploy (push) Waiting to run
test / test (push) Waiting to run
fix(sites): roll back failed domain claims (#119)
* test(sites): reproduce stale failed domain claims

Signed-off-by: RissRIce <jsdavid278@gmail.com>

* fix(sites): roll back failed domain claims

Signed-off-by: RissRIce <jsdavid278@gmail.com>

---------

Signed-off-by: RissRIce <jsdavid278@gmail.com>
2026-08-13 02:34:30 -07:00

190 lines
6 KiB
Go

// Package sites maps members' custom domains onto their homepage (the
// public_html that is also served at /~name).
//
// How it works without a custom Caddy module:
//
// - The DB (store.domains) is the source of truth for domain→user.
// - A symlink farm at <data>/domains/<domain> -> <data>/users/<name>/public_html
// lets Caddy serve any mapped domain with `root * <data>/domains/{host}`.
// - AskHandler answers Caddy's on-demand-TLS query so certificates are only
// issued for domains that are actually mapped (no open cert relay).
//
// A member points DNS (CNAME or A) at the BBS host; the moment a TLS handshake
// for that domain arrives, Caddy asks us, gets a 200, provisions a cert, and
// serves their homepage. See docs/custom-domains.md.
package sites
import (
"errors"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/profullstack/agentbbs/internal/store"
)
// ErrInvalidDomain is returned for syntactically invalid hostnames.
var ErrInvalidDomain = errors.New("invalid domain")
// A conservative DNS hostname: lowercase labels, a real TLD, no scheme/path.
var domainRe = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`)
// Normalize lowercases and trims a domain (drops a trailing dot, any scheme).
func Normalize(d string) string {
d = strings.ToLower(strings.TrimSpace(d))
d = strings.TrimPrefix(d, "https://")
d = strings.TrimPrefix(d, "http://")
d = strings.TrimSuffix(d, "/")
return strings.TrimSuffix(d, ".")
}
// Valid reports whether d is a usable custom domain.
func Valid(d string) bool { return len(d) <= 253 && domainRe.MatchString(d) }
// Manager owns the symlink farm and the on-demand-TLS ask endpoint.
type Manager struct {
st store.Store
usersDir string // <data>/users
domDir string // <data>/domains (the symlink farm Caddy serves)
baseHost string // AGENTBBS_HOST, e.g. bbs.profullstack.com (for <name>.<host>)
}
// NewManager prepares the symlink farm under dataDir/domains.
func NewManager(st store.Store, dataDir string) (*Manager, error) {
domDir := filepath.Join(dataDir, "domains")
if err := os.MkdirAll(domDir, 0o755); err != nil {
return nil, err
}
return &Manager{
st: st,
usersDir: filepath.Join(dataDir, "users"),
domDir: domDir,
baseHost: strings.ToLower(strings.TrimSpace(os.Getenv("AGENTBBS_HOST"))),
}, nil
}
// Add maps domain→username (DB row + symlink). Returns store.ErrDomainTaken if
// another member already owns it, or ErrInvalidDomain on a malformed host.
func (m *Manager) Add(domain, username string) (string, error) {
domain = Normalize(domain)
if !Valid(domain) {
return "", ErrInvalidDomain
}
created, err := m.st.MapDomain(domain, username)
if err != nil {
return domain, err
}
if err := m.link(domain, username); err != nil {
if created {
if rollbackErr := m.st.UnmapDomain(domain, username); rollbackErr != nil {
return domain, errors.Join(err, rollbackErr)
}
}
return domain, err
}
return domain, nil
}
// Remove unmaps a domain owned by username (DB row + symlink).
func (m *Manager) Remove(domain, username string) (string, error) {
domain = Normalize(domain)
if err := m.st.UnmapDomain(domain, username); err != nil {
return domain, err
}
_ = os.Remove(filepath.Join(m.domDir, domain))
return domain, nil
}
// List returns the domains mapped to username.
func (m *Manager) List(username string) ([]string, error) {
return m.st.DomainsForUser(username)
}
// Sync rebuilds the symlink farm from the DB. Run at startup so the farm
// survives a wiped data dir or hand edits, and so the DB stays authoritative.
func (m *Manager) Sync() error {
all, err := m.st.AllDomains()
if err != nil {
return err
}
wanted := make(map[string]struct{}, len(all))
for _, dm := range all {
wanted[dm.Domain] = struct{}{}
}
entries, err := os.ReadDir(m.domDir)
if err != nil {
return err
}
for _, entry := range entries {
if _, ok := wanted[entry.Name()]; ok {
continue
}
if err := os.RemoveAll(filepath.Join(m.domDir, entry.Name())); err != nil {
return err
}
}
for _, dm := range all {
if err := m.link(dm.Domain, dm.Username); err != nil {
return err
}
}
return nil
}
// link points <domDir>/<domain> at the user's public_html, replacing any stale
// link. The public_html is created if absent so the cert + serve path is ready
// before the member has logged in to seed a homepage.
func (m *Manager) link(domain, username string) error {
if !Valid(domain) {
return ErrInvalidDomain
}
target := filepath.Join(m.usersDir, username, "public_html")
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
link := filepath.Join(m.domDir, domain)
_ = os.Remove(link)
return os.Symlink(target, link)
}
// AskHandler answers Caddy's on-demand-TLS query: 200 when the domain is
// mapped (so a cert may be issued), 404 otherwise. Caddy passes the requested
// host as ?domain=. Bind this to loopback only.
func (m *Manager) AskHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
d := Normalize(r.URL.Query().Get("domain"))
if d == "" || !Valid(d) {
http.Error(w, "bad domain", http.StatusBadRequest)
return
}
// Free user-homepage subdomain: <name>.<baseHost> for any registered
// member (e.g. alice.bbs.profullstack.com). Single label only.
if m.baseHost != "" && strings.HasSuffix(d, "."+m.baseHost) {
label := strings.TrimSuffix(d, "."+m.baseHost)
if label != "" && !strings.Contains(label, ".") {
if _, ok, err := m.st.UserByName(label); err != nil {
http.Error(w, "lookup error", http.StatusInternalServerError)
return
} else if ok {
w.WriteHeader(http.StatusOK)
return
}
}
}
if _, ok, err := m.st.DomainUser(d); err != nil {
http.Error(w, "lookup error", http.StatusInternalServerError)
return
} else if !ok {
http.Error(w, "unknown domain", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
})
}
// ServeAsk runs the ask endpoint (blocking). Intended for a goroutine.
func (m *Manager) ServeAsk(addr string) error {
return http.ListenAndServe(addr, m.AskHandler())
}