mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
Add a key-gated `ssh passwd@host` route (alias `password@`) that sets ONE
member-chosen password across every service with its own credential:
- git (Forgejo) new forgejo.SetPassword (PATCH /admin/users, clears
must_change; EnsureUser first so the account exists)
- mail (Mailu webmail) existing mailu.SetPassword
- chat (IRC/Ergo + The Lounge) new internal/ircpass package
Because the route authenticates by the member's registered SSH key, it also
serves as the forgot-password path — no old password required.
The BBS runs as a non-root service user, but the Ergo password store and The
Lounge user files are root-owned. internal/ircpass bridges this by shelling out
to scripts/set-irc-password.sh through a narrow sudoers rule (installed by
setup.sh). The new password travels on stdin (a new `set-irc-password.sh
<member> -` form), so it never appears in the process table or sudo's log.
UX: masked entry typed twice (readSecret); no-PTY reads stdin; empty input
generates a strong password and shows it once. Each service leg is independent
and best-effort with a per-service ✓/✗ summary, plus a confirmation email that
never contains the password.
Tests: ircpass (stdin contract + member/password rejection), forgejo.SetPassword,
auth IsPasswdName + reservation. Docs: credentials.md (passwd@ section) + irc.md.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
75 lines
2.2 KiB
Go
75 lines
2.2 KiB
Go
package ircpass
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestConfiguredRequiresScript(t *testing.T) {
|
|
if (Config{}).Configured() {
|
|
t.Fatal("empty config should not be Configured")
|
|
}
|
|
if !(Config{Script: "/x/set-irc-password.sh"}).Configured() {
|
|
t.Fatal("config with a script path should be Configured")
|
|
}
|
|
}
|
|
|
|
func TestSetPasswordRunsHelperWithArgs(t *testing.T) {
|
|
dir := t.TempDir()
|
|
out := filepath.Join(dir, "args.txt")
|
|
script := filepath.Join(dir, "set-irc-password.sh")
|
|
// A fake helper mirroring the real contract: member in $1, "-" in $2, and the
|
|
// password on stdin. Records member + stdin so the test can assert both.
|
|
body := "#!/bin/sh\nread pw\nprintf '%s\\n%s\\n%s\\n' \"$1\" \"$2\" \"$pw\" > " + out + "\n"
|
|
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
c := Config{Script: script, UseSudo: false}
|
|
if err := c.SetPassword("alice", "s3cret-pw"); err != nil {
|
|
t.Fatalf("SetPassword: %v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// member as argv[0], "-" sentinel as argv[1], password only on stdin.
|
|
want := "alice\n-\ns3cret-pw\n"
|
|
if string(got) != want {
|
|
t.Fatalf("helper got %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSetPasswordRejectsBadMember(t *testing.T) {
|
|
c := Config{Script: "/bin/true", UseSudo: false}
|
|
for _, bad := range []string{"", "-rf", "a b", "alice;rm", "../etc", "Alice"} {
|
|
if err := c.SetPassword(bad, "pw"); err == nil {
|
|
t.Fatalf("expected error for member %q", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetPasswordRejectsBadPassword(t *testing.T) {
|
|
for _, bad := range []string{"", "with\nnewline", "carriage\rreturn"} {
|
|
if err := (Config{Script: "/bin/true"}).SetPassword("alice", bad); err == nil {
|
|
t.Fatalf("expected error for password %q", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSetPasswordUnconfigured(t *testing.T) {
|
|
if err := (Config{}).SetPassword("alice", "pw"); err == nil ||
|
|
!strings.Contains(err.Error(), "not configured") {
|
|
t.Fatalf("want not-configured error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetPasswordSurfacesHelperFailure(t *testing.T) {
|
|
c := Config{Script: "/bin/false", UseSudo: false}
|
|
if err := c.SetPassword("alice", "pw"); err == nil {
|
|
t.Fatal("expected error when helper exits non-zero")
|
|
}
|
|
}
|