feat(irc): irc.profullstack.com host, OS-user (tilde.town) gate, premium channels

Hostname: serve the network as irc.profullstack.com (new IRC_DOMAIN var,
default irc.<root-of-DOMAIN>). Caddy serves an irc.profullstack.com site so it
gets a Let's Encrypt cert; ergo-refresh-certs copies that into Ergo for 6697.
Needs an A record irc.profullstack.com -> the box (self-signed until it resolves).

Members are OS users (tilde.town model): setup.sh reconciles a real OS account
per member dir (root-side, on each deploy + the 15-min timer; nologin shell, so
identity-only — no shell access). The IRC auth-script now gates on
`getent passwd` with uid>=1000 instead of the member dir, so "OS user" == member.

Premium channels: free members may /join; creating channels is a premium perk.
The ssh irc@ client gains /create #name (premium-gated via ensurePremium): it
joins the fresh channel and registers it with ChanServ as the member's founder.
v1 gate is route-level (operator-only-creation left off); external-client
creation hardening is a follow-up. See docs/irc.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 15:23:44 +00:00
parent d4ada98b69
commit 5eb1e96480
5 changed files with 194 additions and 87 deletions

View file

@ -24,18 +24,20 @@ var (
)
// Run drives the IRC TUI over the SSH session until the member leaves; leaving
// ends the session (irc@ is a dedicated route, like agent@).
func Run(s ssh.Session, c *Client) error {
// ends the session (irc@ is a dedicated route, like agent@). canCreate gates the
// /create command on premium membership.
func Run(s ssh.Session, c *Client, canCreate bool) error {
ptyReq, winCh, hasPty := s.Pty()
if !hasPty {
_, _ = s.Write([]byte("irc needs a terminal (ssh -t irc@<host>)\r\n"))
return nil
}
m := &model{
c: c,
channel: DefaultChannel,
width: ptyReq.Window.Width,
height: ptyReq.Window.Height,
c: c,
channel: DefaultChannel,
canCreate: canCreate,
width: ptyReq.Window.Width,
height: ptyReq.Window.Height,
}
m.lines = append(m.lines,
cSys.Render(fmt.Sprintf("connected as %s — joined %s. /help for commands, esc to leave.", c.Nick(), DefaultChannel)))
@ -62,10 +64,11 @@ func waitEvent(c *Client) tea.Cmd {
}
type model struct {
c *Client
channel string // current conversation target for typed lines
lines []string
input string
c *Client
channel string // current conversation target for typed lines
canCreate bool // premium members may /create (register) new channels
lines []string
input string
width, height int
}
@ -159,7 +162,27 @@ func (m *model) command(text string) tea.Cmd {
arg := strings.TrimSpace(strings.TrimPrefix(text, fields[0]))
switch cmd {
case "/help":
m.push(cSys.Render("commands: /join #chan /part [#chan] /msg <nick> <text> /me <action> /names /nick <name> /quit"))
m.push(cSys.Render("commands: /join #chan /part [#chan] /create #chan /msg <nick> <text> /me <action> /names /nick <name> /quit"))
case "/create":
if !m.canCreate {
m.push(cErr.Render("creating channels is a Founding Lifetime Member perk — upgrade with: ssh join@ (the BBS). You can still /join existing channels."))
break
}
ch := arg
if ch == "" {
m.push(cErr.Render("usage: /create #channel"))
break
}
if !strings.HasPrefix(ch, "#") {
ch = "#" + ch
}
// operator-only-creation is off, so joining a fresh channel creates it and
// ops the creator; registering it with ChanServ makes it persist with you
// as founder. (Both run in order on this connection.)
_ = m.c.Join(ch)
_ = m.c.Privmsg("ChanServ", "REGISTER "+ch)
m.channel = ch
m.push(cSys.Render("created " + ch + " — you're the founder. Share the name so members can /join it."))
case "/join":
if arg == "" {
m.push(cErr.Render("usage: /join #channel"))