|
10 | 10 | // ssh domain@host point your own domain at your homepage (Premium; add/rm/list) |
11 | 11 | // ssh <name>@host (from another account) prints a finger card for that member |
12 | 12 | // ssh msg@host U leave member U a message: `ssh msg@host U hi` or pipe stdin |
| 13 | +// ssh passwd@host reset ONE password across git + mail + chat (forgot-password; |
| 14 | +// key-gated, password@ is an alias). See docs/credentials.md |
13 | 15 | // ssh admin@host the operator admin console ($AGENTBBS_ADMINS only; |
14 | 16 | // sysop@/root@ are aliases) |
15 | 17 | // ssh game@host G AgentGames: play game G (e.g. ttt, c4) over NDJSON; rated, |
@@ -65,6 +67,7 @@ import ( |
65 | 67 | "github.com/profullstack/agentbbs/internal/forgejo" |
66 | 68 | "github.com/profullstack/agentbbs/internal/games" |
67 | 69 | "github.com/profullstack/agentbbs/internal/hub" |
| 70 | + "github.com/profullstack/agentbbs/internal/ircpass" |
68 | 71 | "github.com/profullstack/agentbbs/internal/mail" |
69 | 72 | "github.com/profullstack/agentbbs/internal/mailbox" |
70 | 73 | "github.com/profullstack/agentbbs/internal/mailu" |
@@ -114,6 +117,7 @@ type app struct { |
114 | 117 | mailHost string // mail server host (IMAP/SMTP), e.g. mail.profullstack.com |
115 | 118 | webmailURL string // webmail (Roundcube) URL shown to members |
116 | 119 | forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning |
| 120 | + irc ircpass.Config // chat/IRC password reset bridge (privileged helper) |
117 | 121 | live *liveReg // in-memory live-session registry (admin console) |
118 | 122 | files *files.Service // SFTP file storage (nil when AGENTBBS_FILES=0) |
119 | 123 | gamesReg *games.Registry // AgentGames catalog |
@@ -190,6 +194,7 @@ func main() { |
190 | 194 | mailHost: mailHost, |
191 | 195 | webmailURL: env("AGENTBBS_WEBMAIL_URL", "https://"+mailHost), |
192 | 196 | forgejo: forgejo.ConfigFromEnv(), |
| 197 | + irc: ircpass.ConfigFromEnv(), |
193 | 198 | live: newLiveReg(), |
194 | 199 | dataDir: dataDir, |
195 | 200 | assets: env("AGENTBBS_ASSETS", "./assets"), |
@@ -384,6 +389,8 @@ func (a *app) router() wish.Middleware { |
384 | 389 | filesAdminHandler(s) |
385 | 390 | case auth.IsMsgName(user): |
386 | 391 | a.handleMsg(s) |
| 392 | + case auth.IsPasswdName(user): |
| 393 | + a.handlePasswd(s) |
387 | 394 | case isVideo: |
388 | 395 | a.handleVideo(s, code) |
389 | 396 | case user == "agent": |
@@ -631,6 +638,36 @@ func readLine(s ssh.Session, in *bufio.Reader) (string, error) { |
631 | 638 | } |
632 | 639 | } |
633 | 640 |
|
| 641 | +// readSecret reads a line like readLine but echoes '*' for each character instead |
| 642 | +// of the character itself, so a password isn't shown on screen. Same raw-PTY |
| 643 | +// handling (accept '\r' or '\n', handle backspace, abort on Ctrl-C/Ctrl-D). |
| 644 | +func readSecret(s ssh.Session, in *bufio.Reader) (string, error) { |
| 645 | + var b []byte |
| 646 | + for { |
| 647 | + c, err := in.ReadByte() |
| 648 | + if err != nil { |
| 649 | + return "", err |
| 650 | + } |
| 651 | + switch c { |
| 652 | + case '\r', '\n': |
| 653 | + wish.Print(s, "\r\n") |
| 654 | + return string(b), nil |
| 655 | + case 0x03, 0x04: // Ctrl-C / Ctrl-D: treat as abort |
| 656 | + return "", io.EOF |
| 657 | + case 0x7f, '\b': |
| 658 | + if len(b) > 0 { |
| 659 | + b = b[:len(b)-1] |
| 660 | + wish.Print(s, "\b \b") |
| 661 | + } |
| 662 | + default: |
| 663 | + if c >= 0x20 { |
| 664 | + b = append(b, c) |
| 665 | + wish.Print(s, "*") |
| 666 | + } |
| 667 | + } |
| 668 | + } |
| 669 | +} |
| 670 | + |
634 | 671 | // handleJoin runs onboarding interactively in one SSH session: register the |
635 | 672 | // visitor's key, confirm their email with a code we email them, then offer the |
636 | 673 | // $99 Founding Lifetime membership (CoinPay). It then disconnects. |
@@ -1738,6 +1775,194 @@ func (a *app) handleMsg(s ssh.Session) { |
1738 | 1775 | _ = s.Exit(0) |
1739 | 1776 | } |
1740 | 1777 |
|
| 1778 | +// handlePasswd is the self-service "reset my password everywhere" route. It is |
| 1779 | +// gated by the caller's registered SSH key (so it doubles as the forgot-password |
| 1780 | +// path — no old password needed) and sets ONE new password across every service |
| 1781 | +// that has its own credential: git (Forgejo), mail (Mailu webmail), and chat |
| 1782 | +// (IRC + The Lounge). Git push and BBS/SSH access are unaffected — those use the |
| 1783 | +// member's key, not a password. |
| 1784 | +// |
| 1785 | +// ssh passwd@host interactive: type a new password (twice), applied everywhere |
| 1786 | +// ssh passwd@host < file non-interactive: read the new password from stdin |
| 1787 | +// echo | ssh passwd@host empty stdin / no PTY: a strong password is generated for you |
| 1788 | +func (a *app) handlePasswd(s ssh.Session) { |
| 1789 | + fp := auth.Fingerprint(s.PublicKey()) |
| 1790 | + if fp == "" { |
| 1791 | + wish.Println(s, "passwd@ needs your registered SSH key. New here? ssh join@"+a.host) |
| 1792 | + _ = s.Exit(1) |
| 1793 | + return |
| 1794 | + } |
| 1795 | + u, found, err := a.st.UserByFingerprint(fp) |
| 1796 | + if err != nil || !found { |
| 1797 | + wish.Println(s, "key not registered — run: ssh join@"+a.host) |
| 1798 | + _ = s.Exit(1) |
| 1799 | + return |
| 1800 | + } |
| 1801 | + if u.Banned { |
| 1802 | + wish.Println(s, "this account is suspended.") |
| 1803 | + _ = s.Exit(1) |
| 1804 | + return |
| 1805 | + } |
| 1806 | + |
| 1807 | + pw, generated, err := a.readNewPassword(s) |
| 1808 | + if err != nil { |
| 1809 | + wish.Println(s, "password reset cancelled.") |
| 1810 | + _ = s.Exit(1) |
| 1811 | + return |
| 1812 | + } |
| 1813 | + |
| 1814 | + wish.Println(s, "") |
| 1815 | + wish.Println(s, "Setting your password across services…") |
| 1816 | + |
| 1817 | + type result struct{ label, detail string } |
| 1818 | + var ok, failed []result |
| 1819 | + |
| 1820 | + // git (Forgejo): make sure the account exists, then set the chosen password. |
| 1821 | + if a.forgejo.Configured() { |
| 1822 | + if _, _, e := a.forgejo.EnsureUser(u.Name, u.Email); e != nil { |
| 1823 | + failed = append(failed, result{"git ", e.Error()}) |
| 1824 | + } else if e := a.forgejo.SetPassword(u.Name, pw); e != nil { |
| 1825 | + failed = append(failed, result{"git ", e.Error()}) |
| 1826 | + } else { |
| 1827 | + ok = append(ok, result{"git ", a.forgejo.LoginURL() + " (username: " + u.Name + ")"}) |
| 1828 | + } |
| 1829 | + } |
| 1830 | + |
| 1831 | + // mail (Mailu webmail): ensure the mailbox exists, then set its password. |
| 1832 | + if a.mailEnabled() { |
| 1833 | + if e := a.ensureMailbox(u); e != nil { |
| 1834 | + failed = append(failed, result{"mail", e.Error()}) |
| 1835 | + } else { |
| 1836 | + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) |
| 1837 | + e := a.mailu.SetPassword(ctx, u.Name, a.mailDomain, pw) |
| 1838 | + cancel() |
| 1839 | + if e != nil { |
| 1840 | + failed = append(failed, result{"mail", e.Error()}) |
| 1841 | + } else { |
| 1842 | + ok = append(ok, result{"mail", a.webmailURL + " (" + a.mailAddress(u.Name) + ")"}) |
| 1843 | + } |
| 1844 | + } |
| 1845 | + } |
| 1846 | + |
| 1847 | + // chat (IRC + The Lounge): set via the privileged helper. |
| 1848 | + if a.irc.Configured() { |
| 1849 | + if e := a.irc.SetPassword(u.Name, pw); e != nil { |
| 1850 | + failed = append(failed, result{"chat", e.Error()}) |
| 1851 | + } else { |
| 1852 | + ok = append(ok, result{"chat", "SASL on irc." + rootDomain(a.host) + " / web — account: " + u.Name}) |
| 1853 | + } |
| 1854 | + } |
| 1855 | + |
| 1856 | + wish.Println(s, "") |
| 1857 | + if generated { |
| 1858 | + wish.Println(s, "Your new password (save it now — it isn't shown again):") |
| 1859 | + wish.Println(s, " "+pw) |
| 1860 | + wish.Println(s, "") |
| 1861 | + } |
| 1862 | + for _, r := range ok { |
| 1863 | + wish.Println(s, " ✓ "+r.label+" "+r.detail) |
| 1864 | + } |
| 1865 | + for _, r := range failed { |
| 1866 | + wish.Println(s, " ✗ "+r.label+" "+r.detail) |
| 1867 | + } |
| 1868 | + if len(ok) == 0 && len(failed) == 0 { |
| 1869 | + wish.Println(s, " (no password-backed services are configured on this server)") |
| 1870 | + } |
| 1871 | + |
| 1872 | + _, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), "passwd") |
| 1873 | + |
| 1874 | + // Best-effort confirmation email (never contains the password). Skipped when |
| 1875 | + // the member has no verified address or SMTP isn't configured. |
| 1876 | + if u.EmailVerified && u.Email != "" && a.mail.Configured() { |
| 1877 | + _ = a.mail.Send(u.Email, "Your "+rootDomain(a.host)+" password was changed", |
| 1878 | + passwdChangedEmailBody(u.Name, len(ok), remoteIP(s))) |
| 1879 | + } |
| 1880 | + |
| 1881 | + if len(failed) > 0 { |
| 1882 | + _ = s.Exit(1) |
| 1883 | + return |
| 1884 | + } |
| 1885 | + _ = s.Exit(0) |
| 1886 | +} |
| 1887 | + |
| 1888 | +// readNewPassword obtains the member's new password. With a PTY it prompts twice |
| 1889 | +// (masked) and requires the two entries to match and meet a minimum length. With |
| 1890 | +// no PTY it reads the password from stdin; if stdin is empty it generates a strong |
| 1891 | +// one and returns generated=true so the caller shows it to the member. |
| 1892 | +func (a *app) readNewPassword(s ssh.Session) (pw string, generated bool, err error) { |
| 1893 | + const minLen = 8 |
| 1894 | + _, _, isPTY := s.Pty() |
| 1895 | + if !isPTY { |
| 1896 | + b, _ := io.ReadAll(io.LimitReader(s, 4096)) |
| 1897 | + piped := strings.TrimSpace(string(b)) |
| 1898 | + if piped == "" { |
| 1899 | + gen, e := randPassword() |
| 1900 | + return gen, true, e |
| 1901 | + } |
| 1902 | + if len(piped) < minLen { |
| 1903 | + return "", false, fmt.Errorf("password too short") |
| 1904 | + } |
| 1905 | + return piped, false, nil |
| 1906 | + } |
| 1907 | + |
| 1908 | + in := bufio.NewReader(s) |
| 1909 | + for { |
| 1910 | + wish.Print(s, "New password (min "+fmt.Sprint(minLen)+" chars, blank to generate one): ") |
| 1911 | + first, e := readSecret(s, in) |
| 1912 | + if e != nil { |
| 1913 | + return "", false, e |
| 1914 | + } |
| 1915 | + if first == "" { |
| 1916 | + gen, e := randPassword() |
| 1917 | + return gen, true, e |
| 1918 | + } |
| 1919 | + if len(first) < minLen { |
| 1920 | + wish.Println(s, " too short — try again.") |
| 1921 | + continue |
| 1922 | + } |
| 1923 | + wish.Print(s, "Confirm new password: ") |
| 1924 | + second, e := readSecret(s, in) |
| 1925 | + if e != nil { |
| 1926 | + return "", false, e |
| 1927 | + } |
| 1928 | + if first != second { |
| 1929 | + wish.Println(s, " passwords didn't match — try again.") |
| 1930 | + continue |
| 1931 | + } |
| 1932 | + return first, false, nil |
| 1933 | + } |
| 1934 | +} |
| 1935 | + |
| 1936 | +// randPassword returns a strong URL-safe-ish random password (24 hex chars). |
| 1937 | +func randPassword() (string, error) { |
| 1938 | + var b [12]byte |
| 1939 | + if _, err := rand.Read(b[:]); err != nil { |
| 1940 | + return "", err |
| 1941 | + } |
| 1942 | + return hex.EncodeToString(b[:]), nil |
| 1943 | +} |
| 1944 | + |
| 1945 | +// rootDomain strips the first label off a host (bbs.profullstack.com → |
| 1946 | +// profullstack.com) so user-facing copy can name the shared apex. |
| 1947 | +func rootDomain(host string) string { |
| 1948 | + if i := strings.IndexByte(host, '.'); i >= 0 && strings.Contains(host[i+1:], ".") { |
| 1949 | + return host[i+1:] |
| 1950 | + } |
| 1951 | + return host |
| 1952 | +} |
| 1953 | + |
| 1954 | +// passwdChangedEmailBody is the security-notice email sent after a successful |
| 1955 | +// reset. It deliberately never includes the password. |
| 1956 | +func passwdChangedEmailBody(name string, services int, ip string) string { |
| 1957 | + return "Hi " + name + ",\n\n" + |
| 1958 | + "Your password was just changed across your account services (git, mail, chat)" + |
| 1959 | + " via ssh passwd@.\n\n" + |
| 1960 | + fmt.Sprintf(" services updated: %d\n", services) + |
| 1961 | + " request IP: " + ip + "\n\n" + |
| 1962 | + "If this wasn't you, your SSH key may be compromised — rotate it and contact the operator.\n\n" + |
| 1963 | + "Note: git push and BBS/SSH login use your SSH key, not this password.\n" |
| 1964 | +} |
| 1965 | + |
1741 | 1966 | // handleFinger prints a classic finger card when someone ssh's to an |
1742 | 1967 | // existing account name that isn't their own (e.g. ssh anthony@host). |
1743 | 1968 | // Returns false when the route should fall through to the hub. |
|
0 commit comments