feat: add traditional IRC wire protocol listener on configurable port
All checks were successful
check / check (push) Successful in 58s
All checks were successful
check / check (push) Successful in 58s
Add a backward-compatible IRC protocol listener (RFC 1459/2812) that allows standard IRC clients (irssi, weechat, hexchat, etc.) to connect directly via TCP. Key features: - TCP listener on configurable port (IRC_LISTEN_ADDR env var, e.g. :6667) - Full IRC wire protocol parsing and formatting - Connection registration (NICK + USER + optional PASS) - Channel operations: JOIN, PART, MODE, TOPIC, NAMES, LIST, KICK, INVITE - Messaging: PRIVMSG, NOTICE (channel and direct) - Info commands: WHO, WHOIS, LUSERS, MOTD, AWAY - Operator support: OPER (with configured credentials) - PING/PONG keepalive - CAP negotiation (for modern client compatibility) - Full bridge to HTTP/JSON API (shared DB, broker, sessions) - Real-time message relay via broker notifications - Comprehensive test suite (parser + integration tests) The IRC listener is an optional component — disabled when IRC_LISTEN_ADDR is empty (the default). The Broker is now an Fx-provided dependency shared between HTTP handlers and the IRC server. closes #89
This commit is contained in:
153
internal/ircserver/server.go
Normal file
153
internal/ircserver/server.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package ircserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// Params defines the dependencies for creating an IRC
|
||||
// Server.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Config *config.Config
|
||||
Database *db.Database
|
||||
Broker *broker.Broker
|
||||
}
|
||||
|
||||
// Server is the TCP IRC protocol server.
|
||||
type Server struct {
|
||||
log *slog.Logger
|
||||
cfg *config.Config
|
||||
database *db.Database
|
||||
brk *broker.Broker
|
||||
listener net.Listener
|
||||
mu sync.Mutex
|
||||
conns map[*Conn]struct{}
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// New creates a new IRC Server and registers its lifecycle
|
||||
// hooks. The listener is only started if IRC_LISTEN_ADDR
|
||||
// is configured; otherwise the server is inert.
|
||||
func New(
|
||||
lifecycle fx.Lifecycle,
|
||||
params Params,
|
||||
) *Server {
|
||||
srv := &Server{
|
||||
log: params.Logger.Get(),
|
||||
cfg: params.Config,
|
||||
database: params.Database,
|
||||
brk: params.Broker,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
listener: nil,
|
||||
cancel: nil,
|
||||
mu: sync.Mutex{},
|
||||
}
|
||||
|
||||
listenAddr := params.Config.IRCListenAddr
|
||||
if listenAddr == "" {
|
||||
return srv
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
return srv.start(ctx, listenAddr)
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
srv.stop()
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
// start begins listening for TCP connections.
|
||||
//
|
||||
//nolint:contextcheck // long-lived server ctx, not the short Fx one
|
||||
func (s *Server) start(_ context.Context, addr string) error {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("irc listen: %w", err)
|
||||
}
|
||||
|
||||
s.listener = ln
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
|
||||
s.log.Info(
|
||||
"irc server listening", "addr", addr,
|
||||
)
|
||||
|
||||
go s.acceptLoop(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stop shuts down the listener and all connections.
|
||||
func (s *Server) stop() {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
|
||||
if s.listener != nil {
|
||||
s.listener.Close() //nolint:errcheck,gosec
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
for c := range s.conns {
|
||||
c.conn.Close() //nolint:errcheck,gosec
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// acceptLoop accepts new connections.
|
||||
func (s *Server) acceptLoop(ctx context.Context) {
|
||||
for {
|
||||
tcpConn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
s.log.Error(
|
||||
"irc accept error", "error", err,
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
client := newConn(
|
||||
ctx, tcpConn, s.log,
|
||||
s.database, s.brk, s.cfg,
|
||||
)
|
||||
|
||||
s.mu.Lock()
|
||||
s.conns[client] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.conns, client)
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
client.serve(ctx)
|
||||
}()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user