Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ RUN touch /opt/ircu/lib/ircd.motd && chown ircu:ircu /opt/ircu/lib/ircd.motd
COPY tests/docker/iauth-tilded.pl /opt/ircu/bin/iauth-tilded.pl
RUN chmod +x /opt/ircu/bin/iauth-tilded.pl && chown ircu:ircu /opt/ircu/bin/iauth-tilded.pl

COPY tests/docker/iauth-test.pl /opt/ircu/bin/iauth-test.pl
RUN chmod +x /opt/ircu/bin/iauth-test.pl && chown ircu:ircu /opt/ircu/bin/iauth-test.pl

COPY tests/docker/ircd-entrypoint.sh /opt/ircu/lib/ircd-entrypoint.sh
RUN chmod 755 /opt/ircu/lib/ircd-entrypoint.sh

Expand Down
11 changes: 11 additions & 0 deletions doc/readme.features
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,17 @@ AWAY_BURST

Send the away message for clients flagged as away during burst.

JOIN_TARGET
* Type: boolean
* Default: FALSE

Whether the target-change limit (ERR_TARGETTOOFAST) applies to JOIN.
When FALSE, a user may always join channels: a join beyond the free
target budget is not refused and not reported, the channel is simply
charged as a target when the user first speaks on (or parts) it. When
TRUE, joins beyond the budget are refused with ERR_TARGETTOOFAST like
messages to new targets.

CHANNELLEN
* Type: integer
* Default: 200
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ services:
ports:
- "6669:6669"
- "4402:4402"
- "6691:6691"
networks:
ircu-test-net:
ipv4_address: 10.55.0.12
Expand Down
2 changes: 2 additions & 0 deletions include/s_user.h
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ extern void set_snomask(struct Client *, unsigned int, int);
extern int is_snomask(char *);
extern int check_target_limit(struct Client *sptr, struct Client *acptr,
struct Channel *chptr);
extern int check_target_limit_quiet(struct Client *sptr, struct Client *acptr,
struct Channel *chptr);
extern void add_target(struct Client *sptr, void *target);
extern unsigned int umode_make_snomask(unsigned int oldmask, char *arg,
int what);
Expand Down
58 changes: 52 additions & 6 deletions ircd/ircd_lexer.c
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ static const struct lexer_token tokens[] = {
{ "fingerprint", FINGERPRINT },
{ "force_local_opmode", TPRIV_FORCE_LOCAL_OPMODE },
{ "force_opmode", TPRIV_FORCE_OPMODE },
{ "from", FROM },
{ "gb", GBYTES },
{ "gbytes", GBYTES },
{ "general", GENERAL },
Expand Down Expand Up @@ -209,23 +210,42 @@ const char *lexer_position(int *lineno)
return "<undef>";
}

static int lexer_open(const char *fname, int allow_fail, unsigned int allowed)
/** Maximum nesting depth for Include directives. */
#define MAX_INCLUDE_DEPTH 16

/** Push a new input file onto the lexer's stack.
* @param[in] fname Name to report for the file.
* @param[in] fd Open file descriptor, or -1 for a file that yields no
* tokens (yylex() pops it and returns TEOF).
* @param[in] allowed Bitmask of block types permitted in the file.
* @return The new lexer input.
*/
static struct lex_file *lexer_push(const char *fname, int fd, unsigned int allowed)
{
struct lex_file *obj;

obj = MyMalloc(sizeof(*obj));
obj->fd = open(fname, O_RDONLY | O_NOCTTY | O_CLOEXEC);
obj->fd = fd;
DupString(obj->name, fname);
obj->allowed = allowed;
obj->parent = yy_in;
obj->lineno = 1;
obj->tok_ofs = obj->buf_used = 0;
yy_in = obj;
return obj;
}

static int lexer_open(const char *fname, int allow_fail, unsigned int allowed)
{
struct lex_file *obj;

obj = lexer_push(fname, open(fname, O_RDONLY | O_NOCTTY | O_CLOEXEC), allowed);

if (obj->fd < 0) {
yyerror("error opening file");
if (!allow_fail) {
yy_in = obj->parent;
MyFree(obj->name);
MyFree(obj);
return -1;
}
Expand Down Expand Up @@ -274,15 +294,35 @@ int init_lexer(void)

void deinit_lexer(void)
{
assert(!yy_in);

/* A parse that was abandoned (e.g. by a parser stack overflow) leaves
* inputs on the stack; unwind them instead of asserting. */
while (yy_in) {
lexer_pop();
}
}

void lexer_include(const char *fname, unsigned int allowed)
{
struct lex_file *obj;
unsigned int depth = 0;

/* Refuse recursive includes and unreasonable nesting; either would
* otherwise recurse until the parser stack overflows. Push an input
* that yields no tokens so the Include block still ends with TEOF. */
for (obj = yy_in; obj; obj = obj->parent) {
++depth;
if (0 == strcmp(obj->name, fname)) {
lexer_push(fname, -1, allowed);
yyerror("recursive include");
return;
}
}
if (depth >= MAX_INCLUDE_DEPTH) {
lexer_push(fname, -1, allowed);
yyerror("include nesting too deep");
return;
}

lexer_open(fname, 1, allowed);
}

Expand Down Expand Up @@ -315,8 +355,14 @@ int yylex(void)
if (!yy_in)
return YYEOF;

if (yy_in->fd < 0)
return TOKERR;
if (yy_in->fd < 0) {
/* The file could not be opened (lexer_open() already reported it).
* Treat it as an empty file: pop it and end the include, instead of
* returning TOKERR forever and hanging the parser's error recovery.
*/
lexer_pop();
return yy_in ? TEOF : YYEOF;
}

for (;;) {
pos = yy_in->buf + yy_in->tok_ofs;
Expand Down
5 changes: 4 additions & 1 deletion ircd/ircd_parser.y
Original file line number Diff line number Diff line change
Expand Up @@ -1492,7 +1492,10 @@ includeblock: INCLUDE {
} blockspec ';' {
lexer_include($3, flags);
yychar = YYEMPTY;
} blocks TEOF;
} includebody TEOF;

/* An included file may legitimately be empty or contain only comments. */
includebody: /* empty */ | blocks;

blockspec: QSTRING { flags = ~0; }
| blocktypes FROM QSTRING { flags = $1; $$ = $3; };
Expand Down
13 changes: 6 additions & 7 deletions ircd/m_info.c
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,12 @@ int mo_info(struct Client* cptr, struct Client* sptr, int parc, char* parv[])
if (hunt_server_cmd(sptr, CMD_INFO, cptr, 1, ":%C", 1, parc, parv) ==
HUNTED_ISME)
{
while (text[218])
{
if (!IsOper(sptr))
send_reply(sptr, RPL_INFO, *text);
text++;
}
if (IsOper(sptr) && (NULL != parv[1]))
/* The public text ends at the "Sources:" marker (as in m_info());
* the file hash list that follows is only shown to operators who
* asked for a specific server. */
while (*text && strcmp(*text, "Sources:"))
send_reply(sptr, RPL_INFO, *text++);
if (NULL != parv[1])
{
while (*text)
send_reply(sptr, RPL_INFO, *text++);
Expand Down
14 changes: 8 additions & 6 deletions ircd/m_join.c
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,14 @@ last0(struct Client *cptr, struct Client *sptr, char *chanlist)
*/
static int check_target_join(struct Client *cptr, struct Channel *chptr)
{
if (check_target_limit(cptr, NULL, chptr))
{
return feature_bool(FEAT_JOIN_TARGET) ? 1 : CHFL_DELAYED_TARGET;
}

return 0;
if (feature_bool(FEAT_JOIN_TARGET))
return check_target_limit(cptr, NULL, chptr) ? 1 : 0;

/* The join is allowed regardless: only find out whether the target
* budget covered it, without sending ERR_TARGETTOOFAST or applying
* the penalty. If not, the target is charged when the user first
* speaks on (or parts) the channel instead. */
return check_target_limit_quiet(cptr, NULL, chptr) ? CHFL_DELAYED_TARGET : 0;
}

/** Handle a JOIN message from a client connection.
Expand Down
51 changes: 46 additions & 5 deletions ircd/s_user.c
Original file line number Diff line number Diff line change
Expand Up @@ -693,11 +693,13 @@ add_target(struct Client *sptr, void *target)
* @param[in] sptr User trying to join a channel or send a message.
* @param[in] acptr Destination client (NULL if sending to a channel).
* @param[in] chptr Destination channel (NULL if sending to a client).
* @return Non-zero if too many target changes (after sending
* ERR_TARGETTOOFAST); zero if okay to send.
* @param[in] report If non-zero, send ERR_TARGETTOOFAST and apply the
* anti-flood penalty when the limit is hit; if zero, only report the
* verdict (for callers that proceed regardless).
* @return Non-zero if too many target changes; zero if okay to send.
*/
int check_target_limit(struct Client *sptr, struct Client *acptr,
struct Channel *chptr)
static int check_target_limit_int(struct Client *sptr, struct Client *acptr,
struct Channel *chptr, int report)
{
unsigned char hash = hash_target(acptr ? (void *)acptr : chptr);
int i;
Expand Down Expand Up @@ -727,7 +729,7 @@ int check_target_limit(struct Client *sptr, struct Client *acptr,
/* If user is invited to channel, give him/her a free target */
if (chptr && IsInvited(sptr, chptr))
return 0;
if (cli_nexttarget(sptr) - CurrentTime < TARGET_DELAY + 8) {
if (report && cli_nexttarget(sptr) - CurrentTime < TARGET_DELAY + 8) {
const char *name;
/*
* No server flooding
Expand All @@ -750,6 +752,34 @@ int check_target_limit(struct Client *sptr, struct Client *acptr,
return 0;
}

/** Check whether \a sptr can send to or join \a target yet, sending
* ERR_TARGETTOOFAST (and applying the anti-flood penalty) if not.
* @param[in] sptr User trying to join a channel or send a message.
* @param[in] acptr Destination client (NULL if sending to a channel).
* @param[in] chptr Destination channel (NULL if sending to a client).
* @return Non-zero if too many target changes (after sending
* ERR_TARGETTOOFAST); zero if okay to send.
*/
int check_target_limit(struct Client *sptr, struct Client *acptr,
struct Channel *chptr)
{
return check_target_limit_int(sptr, acptr, chptr, 1);
}

/** Like check_target_limit(), but silent: no ERR_TARGETTOOFAST and no
* penalty when the limit is hit. For callers that proceed either way
* and only need to know whether the target was charged now.
* @param[in] sptr User trying to join a channel or send a message.
* @param[in] acptr Destination client (NULL if sending to a channel).
* @param[in] chptr Destination channel (NULL if sending to a client).
* @return Non-zero if too many target changes; zero if okay to send.
*/
int check_target_limit_quiet(struct Client *sptr, struct Client *acptr,
struct Channel *chptr)
{
return check_target_limit_int(sptr, acptr, chptr, 0);
}

/** Allows a channel operator to avoid target change checks when
* sending messages to users on their channel.
* @param[in] source User sending the message.
Expand Down Expand Up @@ -820,6 +850,17 @@ int whisper(struct Client* source, const char* nick, const char* channel,
send_reply(source, RPL_AWAY, cli_name(dest), cli_user(dest)->away);
sendcmdto_one(source, CMD_PRIVATE, dest, "%C :%s", dest, text);
}

/* echo-message: hand the sender a copy, as PRIVMSG/NOTICE do.
* (CMD_* expand to a message/token pair, hence the two calls.) */
if (CapHas(cli_active(source), CAP_ECHOMESSAGE))
{
if (is_notice)
sendcmdto_one(source, CMD_NOTICE, cli_from(source), "%C :%s", dest, text);
else
sendcmdto_one(source, CMD_PRIVATE, cli_from(source), "%C :%s", dest, text);
}

return 0;
}

Expand Down
37 changes: 37 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,33 @@ conftest.py # pytest fixtures (ircd_hub, ircd_network, make_client)
- **test_fix.py** — focused tests that reproduce the bug or verify the feature claimed by the PR. These fail on the base branch and pass with the PR applied.
- **test_edge_cases.py** — adversarial tests that exercise boundary conditions, invalid inputs, and feature interactions. Tests that depend on the PR feature use `pytest.skip()` when it's not available.

## Behaviour suites (main-branch changes since 2019)

Besides the per-PR directories, these suites pin down behaviour changes made
directly on the release branch (each module docstring names the commits):

| Path | What it covers |
|------|----------------|
| `chanmodes/` | channel modes +P (no part/quit messages) and +M (moderate unauthed users) |
| `cap/test_cap_list.py`, `cap/test_extended_join.py`, `cap/test_echo_message.py`, `cap/test_cap_edge_cases_main.py` | capability list, extended-join on every JOIN path, echo-message |
| `relay/` | `NOTICE nick@server`, JOIN target limits (`JOIN_TARGET`), CPRIVMSG idle reset |
| `commands/` | WHOWAS `0`, WHOX `%l`, PART, INFO, CONNECT `0`, PRIVS, remote STATS |
| `features/` | Boolean features (`0`/`1`, spellings), HIS_REMOTE, defaults, removed features |
| `s2s/` | server parser robustness (`END_OF_BURST`, bad numerics), GLINE reason/lifetime updates |
| `username/` | ident / WebIRC username handling, STRICT_USERNAME rules |
| `iauth/` | `/STATS iauth` and `/STATS iauthconf`, asynchronous `? stats2`, IAuth line parsing |
| `config/` | `Include` and the configuration lexer via `ircd -k` inside the hub container |

Shared helpers for these live in `common.py` (`join`, `drain`, `whois`,
`set_feature`, ...). `set_feature()` exists because `SET` only answers when
the value changes and ircu defers a client's commands once its flood penalty
builds up, so "SET + sleep" is racy.

Strict `xfail` markers in `config/test_include.py` document known ircd bugs:
`Include <types> from "file"` is a syntax error (the lexer has no `from`
token), a missing include file makes `ircd -k` hang, and a self-including
file aborts it.

## Docker Topology

Three ircd servers form a test network:
Expand All @@ -112,6 +139,12 @@ Three ircd servers form a test network:
| ircd-leaf1 | leaf1.test.net | 6668 | 4401 | 2 |
| ircd-leaf2 | leaf2.test.net | 6669 | 4402 | 3 |

leaf2 differs from the others: ident lookups are on (`Client { username = "*" }`),
it runs the non-forcing `docker/iauth-test.pl` (policy `ARUS`, supports `? config` /
`? stats2`) instead of `iauth-tilded.pl`, and port 6691 is a WebIRC port
(`WEBIRC webircpass ...`). The hub Connect block `notulined.test.net` points at
port 4499 where nothing listens (CONNECT tests).

| ircd-tls-hub | tls-hub.test.net | 16677 / 16697 | 14440 / 14441 | 10 |
| ircd-tls-leaf | tls-leaf.test.net | 16678 / 16680 | 14411 / 14412 | 11 |

Expand Down Expand Up @@ -227,7 +260,11 @@ The P10 server handles the full handshake (PASS, SERVER, burst, EB/EA), auto-res
```python
client = await make_client("mynick")
client = await make_client("mynick", host="127.0.0.1", port=6668)
client = await make_client("mynick", caps=["extended-join"]) # negotiates CAPs first
```
- **`oper`** (function) — a registered global operator (`testop`) on the hub
- **`ulined_server`** (function) — U:lined fake P10 server (`services.test.net`) linked to the hub
- `docker_exec()` / `docker_cp_text()` — run commands / write files inside a test container

## Writing Tests for a New PR

Expand Down
Loading
Loading