diff --git a/internal/sites/sites.go b/internal/sites/sites.go index ec1bc9b..a6d98db 100644 --- a/internal/sites/sites.go +++ b/internal/sites/sites.go @@ -48,6 +48,7 @@ type Manager struct { st store.Store usersDir string // /users domDir string // /domains (the symlink farm Caddy serves) + baseHost string // AGENTBBS_HOST, e.g. bbs.profullstack.com (for .) } // 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: . 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 diff --git a/internal/sites/sites_test.go b/internal/sites/sites_test.go index 3797419..b408485 100644 --- a/internal/sites/sites_test.go +++ b/internal/sites/sites_test.go @@ -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) + } + } +} diff --git a/setup.sh b/setup.sh index 5a4fe52..769d472 100755 --- a/setup.sh +++ b/setup.sh @@ -381,6 +381,7 @@ fi # Let Caddy (user 'caddy') read the per-user public_html trees. usermod -aG "$SVC_USER" caddy 2>/dev/null || true log "writing Caddyfile" +DOMAIN_RE=$(printf '%s' "$DOMAIN" | sed 's/[.]/\\./g') # dots escaped for Caddy host_regexp cat > /etc/caddy/Caddyfile <.${DOMAIN} (needs wildcard DNS +# *.${DOMAIN} -> this host). On-demand TLS mints a cert only when agentbbs's +# ask endpoint confirms is a registered member, so random subdomains +# can't trigger certificate issuance. +*.${DOMAIN} { + encode zstd gzip + tls { + on_demand + } + @user host_regexp user ^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.${DOMAIN_RE}\$ + handle @user { + root * ${DATA_DIR}/users/{re.user.1}/public_html + try_files {path} {path}/index.html + file_server browse + } + handle { + respond "no such user" 404 + } +} + # Custom domains a member pointed at this host (ssh domain@${DOMAIN} add ...). # The symlink farm in domains/ maps each host to its owner's public_html, so # {host} resolves to the right tree; unmapped hosts 404 (and never got a cert).