From ac4b0873d9a802c913c8e27d39f537d8c1513526 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 14 Jun 2026 10:43:09 +0000 Subject: [PATCH] join@: let new members pick their own username MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onboarding hard-coded the account name to member-, so everyone got an unmemorable handle like member-zafztqdk for ssh @host and /~. New keys are now prompted for a username during join@. auth.SanitizeUsername folds input to the hub/subdomain charset (lowercase [a-z0-9-], 3–20 chars, '_'/space -> '-', no doubled/edge dashes); auth.IsReservedName blocks route and infra labels (bbs/join/pod/domain/admin/agent/video/video-*/www/...). The name must be free (UserByName) or we re-prompt; pressing enter keeps the member- default. Returning keys keep the name they already chose. Co-Authored-By: Claude Opus 4.8 --- cmd/agentbbs/main.go | 58 +++++++++++++++++++++++++++++++++----- internal/auth/auth.go | 47 ++++++++++++++++++++++++++++++ internal/auth/auth_test.go | 45 +++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 7 deletions(-) diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 1018366..ee86427 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -332,21 +332,25 @@ func (a *app) handleJoin(s ssh.Session) { _ = s.Exit(1) return } + in := bufio.NewReader(s) + u, found, err := a.st.UserByFingerprint(fp) - if err == nil && !found { - name := "member-" + strings.ToLower(strings.TrimPrefix(fp, "SHA256:"))[:8] - u, err = a.st.EnsureUser(name, string(auth.Member), fp) - } if err != nil { wish.Fatalln(s, "registration error: "+err.Error()) return } + if !found { + // New key: let the visitor pick their own handle before we create the + // account (a returning key keeps the name it already chose). + wish.Println(s, "\n Welcome to AgentBBS — let's set up your account.") + if u, err = a.registerNewMember(s, in, fp); err != nil { + wish.Fatalln(s, "registration error: "+err.Error()) + return + } + } _, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), "join") - in := bufio.NewReader(s) wish.Println(s, "\n"+strings.Join([]string{ - " Welcome to AgentBBS — let's set up your account.", - "", " account " + u.Name, " key " + fp, }, "\n")) @@ -376,6 +380,46 @@ func (a *app) handleJoin(s ssh.Session) { _ = s.Exit(0) } +// registerNewMember asks the visitor to choose a username, then creates their +// member account under it. The name is sanitized to the hub/subdomain charset, +// rejected if reserved, and must be free; pressing enter accepts a generated +// member- default. Returns the created user. +func (a *app) registerNewMember(s ssh.Session, in *bufio.Reader, fp string) (store.User, error) { + def := "member-" + strings.ToLower(strings.TrimPrefix(fp, "SHA256:"))[:8] + wish.Println(s, "\n Pick a username — letters, numbers and dashes, 3–20 chars.") + wish.Println(s, " It's your handle for ssh @"+a.host+" and https://"+a.host+"/~.") + + for tries := 0; tries < 5; tries++ { + wish.Print(s, "\n Username ["+def+"]: ") + line, err := in.ReadString('\n') + if err != nil { + return store.User{}, err + } + raw := strings.TrimSpace(line) + if raw == "" { + return a.st.EnsureUser(def, string(auth.Member), fp) + } + name, ok := auth.SanitizeUsername(raw) + switch { + case !ok && auth.IsReservedName(name): + wish.Println(s, " \""+name+"\" is reserved — pick another.") + continue + case !ok: + wish.Println(s, " needs 3–20 chars of letters, numbers or dashes — try again.") + continue + } + if _, taken, err := a.st.UserByName(name); err != nil { + return store.User{}, err + } else if taken { + wish.Println(s, " \""+name+"\" is taken — try another.") + continue + } + return a.st.EnsureUser(name, string(auth.Member), fp) + } + wish.Println(s, " Keeping "+def+" for now.") + return a.st.EnsureUser(def, string(auth.Member), fp) +} + // verifyEmailInteractive collects an email, emails a 6-digit code, and prompts // the visitor to type it back. It updates *u and returns true once verified. func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.User) bool { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index dc538bc..edaa680 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -65,6 +65,53 @@ func IsDomainName(u string) bool { return DomainNames[strings.ToLower(u)] } // IsAdminName reports whether the SSH username requests the admin console. func IsAdminName(u string) bool { return AdminNames[strings.ToLower(u)] } +// systemReserved are names that don't drive an SSH route but would still +// collide with a per-user subdomain (.), the agent route, or common +// infra hostnames — so members may not claim them as account names. +var systemReserved = map[string]bool{ + "agent": true, "video": true, "www": true, "api": true, "mail": true, + "smtp": true, "imap": true, "ftp": true, "ns": true, "ns1": true, "ns2": true, + "cdn": true, "static": true, "assets": true, "root": true, "abuse": true, + "postmaster": true, "webmaster": true, "support": true, "help": true, + "admin": true, "sysop": true, "bbs": true, "guest": true, "pod": true, +} + +// IsReservedName reports whether name is claimed by a route or infra label and +// therefore cannot be used as a member's account name. +func IsReservedName(name string) bool { + n := strings.ToLower(name) + if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] || systemReserved[n] { + return true + } + return strings.HasPrefix(n, "video-") // video- call routes +} + +// SanitizeUsername normalizes a requested account name to the charset the hub +// and per-user subdomains allow: lowercased [a-z0-9-], 3–20 chars, with '_' and +// spaces folded to '-', no doubled, leading, or trailing dashes. It returns the +// cleaned name and whether it is usable (right length and not reserved). +func SanitizeUsername(raw string) (string, bool) { + var b strings.Builder + lastDash := false + for _, r := range strings.ToLower(strings.TrimSpace(raw)) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + lastDash = false + case r == '-' || r == '_' || r == ' ': + if b.Len() > 0 && !lastDash { + b.WriteByte('-') + lastDash = true + } + } + } + name := strings.Trim(b.String(), "-") + if len(name) < 3 || len(name) > 20 || IsReservedName(name) { + return name, false + } + return name, true +} + // IsGameName reports whether the SSH username requests the AgentGames protocol. func IsGameName(u string) bool { return GameNames[strings.ToLower(u)] } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 08c8898..aec7996 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -40,3 +40,48 @@ func TestAdminsEmpty(t *testing.T) { t.Error("nobody is admin when allowlist is empty") } } + +func TestSanitizeUsername(t *testing.T) { + cases := []struct { + in string + want string + ok bool + }{ + {"anthony", "anthony", true}, + {" Cool_Name 42 ", "cool-name-42", true}, + {"a--b__c", "a-b-c", true}, + {"-Edge--", "edge", true}, + {"MixedCASE", "mixedcase", true}, + {"ab", "ab", false}, // too short + {"!!", "", false}, // nothing usable + {"this-name-is-way-too-long-to-accept", "", false}, // >20 after... actually long + {"admin", "admin", false}, // reserved (route/infra) + {"pod", "pod", false}, // reserved route + {"video-7f3a", "video-7f3a", false}, // reserved call route + {"WWW", "www", false}, // reserved infra label + } + for _, c := range cases { + got, ok := SanitizeUsername(c.in) + if ok != c.ok { + t.Errorf("SanitizeUsername(%q) ok=%v, want %v (got name %q)", c.in, ok, c.ok, got) + } + // For valid results the cleaned name must match; for invalid ones we + // only assert the usability flag (the cleaned form is advisory). + if c.ok && got != c.want { + t.Errorf("SanitizeUsername(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestIsReservedName(t *testing.T) { + for _, n := range []string{"admin", "bbs", "pod", "join", "domain", "agent", "www", "video", "video-abc", "ROOT"} { + if !IsReservedName(n) { + t.Errorf("IsReservedName(%q) = false, want true", n) + } + } + for _, n := range []string{"anthony", "cool-name-42", "member-zafztqdk"} { + if IsReservedName(n) { + t.Errorf("IsReservedName(%q) = true, want false", n) + } + } +}