feat: add OPER command and oper-only WHOIS client info
Some checks failed
check / check (push) Failing after 1m52s

- Add OPER command with NEOIRC_OPER_NAME/NEOIRC_OPER_PASSWORD config
- Add is_oper column to sessions table
- Add RPL_WHOISACTUALLY (338): show client IP/hostname to opers
- Add RPL_WHOISOPERATOR (313): show oper status in WHOIS
- Add GetOperCount for accurate LUSERS oper count
- Fix README schema: add ip/is_oper to sessions, ip/hostname to clients
- Add OPER command documentation and numeric references to README
- Refactor executeWhois to stay under funlen limit
- Add comprehensive tests for OPER auth, oper WHOIS, non-oper WHOIS

Closes #81
This commit is contained in:
clawbot
2026-03-17 10:48:04 -07:00
parent 16258722c7
commit 3571c50216
9 changed files with 807 additions and 36 deletions

View File

@@ -298,6 +298,75 @@ func (database *Database) GetClientHostInfo(
return &info, nil
}
// SetSessionOper sets the is_oper flag on a session.
func (database *Database) SetSessionOper(
ctx context.Context,
sessionID int64,
isOper bool,
) error {
val := 0
if isOper {
val = 1
}
_, err := database.conn.ExecContext(
ctx,
`UPDATE sessions SET is_oper = ? WHERE id = ?`,
val, sessionID,
)
if err != nil {
return fmt.Errorf("set session oper: %w", err)
}
return nil
}
// IsSessionOper returns whether the session has oper
// status.
func (database *Database) IsSessionOper(
ctx context.Context,
sessionID int64,
) (bool, error) {
var isOper int
err := database.conn.QueryRowContext(
ctx,
`SELECT is_oper FROM sessions WHERE id = ?`,
sessionID,
).Scan(&isOper)
if err != nil {
return false, fmt.Errorf(
"check session oper: %w", err,
)
}
return isOper != 0, nil
}
// GetLatestClientForSession returns the IP and hostname
// of the most recently created client for a session.
func (database *Database) GetLatestClientForSession(
ctx context.Context,
sessionID int64,
) (*ClientHostInfo, error) {
var info ClientHostInfo
err := database.conn.QueryRowContext(
ctx,
`SELECT ip, hostname FROM clients
WHERE session_id = ?
ORDER BY created_at DESC LIMIT 1`,
sessionID,
).Scan(&info.IP, &info.Hostname)
if err != nil {
return nil, fmt.Errorf(
"get latest client for session: %w", err,
)
}
return &info, nil
}
// GetChannelByName returns the channel ID for a name.
func (database *Database) GetChannelByName(
ctx context.Context,
@@ -951,6 +1020,26 @@ func (database *Database) GetUserCount(
return count, nil
}
// GetOperCount returns the number of sessions with oper
// status.
func (database *Database) GetOperCount(
ctx context.Context,
) (int64, error) {
var count int64
err := database.conn.QueryRowContext(
ctx,
"SELECT COUNT(*) FROM sessions WHERE is_oper = 1",
).Scan(&count)
if err != nil {
return 0, fmt.Errorf(
"get oper count: %w", err,
)
}
return count, nil
}
// ClientCountForSession returns the number of clients
// belonging to a session.
func (database *Database) ClientCountForSession(

View File

@@ -887,3 +887,133 @@ func TestEnqueueToClient(t *testing.T) {
t.Fatalf("expected 1, got %d", len(msgs))
}
}
func TestSetAndCheckSessionOper(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err := database.CreateSession(
ctx, "opernick", "", "", "",
)
if err != nil {
t.Fatal(err)
}
// Initially not oper.
isOper, err := database.IsSessionOper(ctx, sessionID)
if err != nil {
t.Fatal(err)
}
if isOper {
t.Fatal("expected session not to be oper")
}
// Set oper.
err = database.SetSessionOper(ctx, sessionID, true)
if err != nil {
t.Fatal(err)
}
isOper, err = database.IsSessionOper(ctx, sessionID)
if err != nil {
t.Fatal(err)
}
if !isOper {
t.Fatal("expected session to be oper")
}
// Unset oper.
err = database.SetSessionOper(ctx, sessionID, false)
if err != nil {
t.Fatal(err)
}
isOper, err = database.IsSessionOper(ctx, sessionID)
if err != nil {
t.Fatal(err)
}
if isOper {
t.Fatal("expected session not to be oper")
}
}
func TestGetLatestClientForSession(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
sessionID, _, _, err := database.CreateSession(
ctx, "clientnick", "", "", "10.0.0.1",
)
if err != nil {
t.Fatal(err)
}
clientInfo, err := database.GetLatestClientForSession(
ctx, sessionID,
)
if err != nil {
t.Fatal(err)
}
if clientInfo.IP != "10.0.0.1" {
t.Fatalf(
"expected IP 10.0.0.1, got %s",
clientInfo.IP,
)
}
}
func TestGetOperCount(t *testing.T) {
t.Parallel()
database := setupTestDB(t)
ctx := t.Context()
// Create two sessions.
sid1, _, _, err := database.CreateSession(
ctx, "user1", "", "", "",
)
if err != nil {
t.Fatal(err)
}
sid2, _, _, err := database.CreateSession(
ctx, "user2", "", "", "",
)
_ = sid2
if err != nil {
t.Fatal(err)
}
// Initially zero opers.
count, err := database.GetOperCount(ctx)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("expected 0 opers, got %d", count)
}
// Set one as oper.
err = database.SetSessionOper(ctx, sid1, true)
if err != nil {
t.Fatal(err)
}
count, err = database.GetOperCount(ctx)
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("expected 1 oper, got %d", count)
}
}

View File

@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS sessions (
username TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
is_oper INTEGER NOT NULL DEFAULT 0,
password_hash TEXT NOT NULL DEFAULT '',
signing_key TEXT NOT NULL DEFAULT '',
away_message TEXT NOT NULL DEFAULT '',