Free per-user homepages at <name>.<host> (wildcard subdomains)

- sites.AskHandler now approves <name>.AGENTBBS_HOST on-demand-TLS when <name>
  is a registered member (so only real users mint subdomain certs).
- Caddyfile: *.${DOMAIN} block serving users/<name>/public_html with on_demand
  TLS; unknown users 404. Needs a wildcard DNS record *.${DOMAIN} -> host.
- Test: TestAskUserSubdomain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 09:20:34 +00:00
parent 74c292f341
commit ae8b1c25f4
3 changed files with 73 additions and 0 deletions

View file

@ -48,6 +48,7 @@ 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.
@ -60,6 +61,7 @@ func NewManager(st store.Store, dataDir string) (*Manager, error) {
st: st,
usersDir: filepath.Join(dataDir, "users"),
domDir: domDir,
baseHost: strings.ToLower(strings.TrimSpace(os.Getenv("AGENTBBS_HOST"))),
}, nil
}
@ -132,6 +134,20 @@ func (m *Manager) AskHandler() http.Handler {
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

View file

@ -110,3 +110,39 @@ func TestManagerAddRemoveSyncAsk(t *testing.T) {
t.Error("expected domain unmapped in store")
}
}
func TestAskUserSubdomain(t *testing.T) {
t.Setenv("AGENTBBS_HOST", "bbs.profullstack.com")
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if _, err := st.EnsureUser("alice", "member", "SHA256:aaa"); err != nil {
t.Fatal(err)
}
m, err := NewManager(st, dir)
if err != nil {
t.Fatal(err)
}
h := m.AskHandler()
for _, c := range []struct {
domain string
code int
}{
{"alice.bbs.profullstack.com", http.StatusOK}, // registered member → cert allowed
{"nobody.bbs.profullstack.com", http.StatusNotFound}, // no such user
{"a.b.bbs.profullstack.com", http.StatusNotFound}, // multi-label, not a user subdomain
{"bbs.profullstack.com", http.StatusNotFound}, // apex is not a user subdomain
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/check?domain="+c.domain, nil)
h.ServeHTTP(rec, req)
if rec.Code != c.code {
t.Errorf("ask %q = %d, want %d", c.domain, rec.Code, c.code)
}
}
}