join@: let new members pick their own username

Onboarding hard-coded the account name to member-<fp8>, so everyone got an
unmemorable handle like member-zafztqdk for ssh <name>@host and /~<name>.

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-<fp8>
default. Returning keys keep the name they already chose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 10:43:09 +00:00
parent 31c00cd65a
commit ac4b0873d9
3 changed files with 143 additions and 7 deletions

View file

@ -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 (<name>.<host>), 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-<code> call routes
}
// SanitizeUsername normalizes a requested account name to the charset the hub
// and per-user subdomains allow: lowercased [a-z0-9-], 320 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)] }

View file

@ -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)
}
}
}