From f14cd364ea04462927df468f7674b1407da34c27 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 20 Oct 2026 18:36:33 +0200 Subject: [PATCH 01/13] Add mcd -t to check a configuration file and exit There has been no way to check a configuration file without starting the daemon. A bad file is only found when mcd exits on it, at start or on SIGHUP, with the old configuration gone. Like nginx -t and sshd -t. The file is read by the same parser, errors are reported as FILE:LINE: message with exit status EX_DATAERR, or EX_NOINPUT when the file cannot be opened. The check runs before the root check and before daemonizing, so any user can run it: $ mcd -t -f /etc/mcd.conf mcd: config file /etc/mcd.conf is ok config_init() now returns -1 when the file is missing, which main() turns into the exit status. The daemon used to start in silence without a configuration file, it now logs the missing file and waits for SIGHUP as before. Signed-off-by: Joachim Wiberg --- ChangeLog.md | 5 +++++ README.md | 9 ++++---- man/mcd.8 | 7 ++++++- src/cfparse.y | 13 ++++++++---- src/defs.h | 2 +- src/main.c | 30 ++++++++++++++++++-------- test/Makefile.am | 5 +++-- test/config-check.sh | 50 ++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 100 insertions(+), 21 deletions(-) create mode 100755 test/config-check.sh diff --git a/ChangeLog.md b/ChangeLog.md index 1520d1b..a0ecf58 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -6,6 +6,11 @@ All relevant, user visible, changes are documented in this file. [UNRELEASED][] -------------- +### Changes + - New `mcd -t` option to check a configuration file and exit, like + `nginx -t`. Errors are reported as `FILE:LINE: message`, and it + runs as a normal user + ### Fixes - Fix `mctl show router-ports` and `show igmp json` missing the router ports of bridges with a hyphen in their name, e.g. `br-0` diff --git a/README.md b/README.md index 80f7d5a..13ea2f1 100644 --- a/README.md +++ b/README.md @@ -243,10 +243,11 @@ it in the foreground with more logging: sudo mcd -n -l info `SIGHUP` reloads the configuration and probes the interfaces again. -To run several instances, give each its own identity with `-i NAME`, -which also names its configuration file, PID file and IPC socket, and -point `mctl -i NAME` at the one you want. The options, signals and -files are in [mcd(8)][]. +To check a configuration file before loading it, run `mcd -t -f FILE` +as any user. To run several instances, give each its own identity +with `-i NAME`, which also names its configuration file, PID file and +IPC socket, and point `mctl -i NAME` at the one you want. The +options, signals and files are in [mcd(8)][]. Building diff --git a/man/mcd.8 b/man/mcd.8 index ce35daf..d8d7f00 100644 --- a/man/mcd.8 +++ b/man/mcd.8 @@ -7,7 +7,7 @@ .Nd IGMP and MLD querier for the Linux bridge .Sh SYNOPSIS .Nm -.Op Fl hnsv +.Op Fl hnstv .Op Fl f Ar FILE .Op Fl i Ar NAME .Op Fl l Ar LEVEL @@ -101,6 +101,11 @@ a reload completes. Log to syslog, the default unless .Fl n is given. +.It Fl t , -check-config +Check the configuration file and exit without starting the daemon. +Errors are reported as +.Ar FILE:LINE: message +with a non-zero exit status. Runs as a normal user. .It Fl u , -ipc Ns = Ns Ar FILE IPC socket used by .Xr mctl 8 , diff --git a/src/cfparse.y b/src/cfparse.y index 05e2931..4a466bc 100644 --- a/src/cfparse.y +++ b/src/cfparse.y @@ -375,22 +375,27 @@ static int yylex(void) return STRING; } -void config_init(void) +int config_init(void) { TAILQ_INIT(&scrap.ifi_static); TAILQ_INIT(&scrap.ifi_groups); lineno = 0; - dbg("Parsing file %s ...", config_file); - if (config_parse(config_file) && errno != ENOENT) { + if (config_parse(config_file)) { + int rc = errno; + err("cannot open %s", config_file); - exit(EX_DATAERR); + if (rc != ENOENT) + exit(EX_DATAERR); + return -1; /* may be created later, wait for SIGHUP */ } /* Unless set, follows the intervals and robustness just read */ if (!router_timeout) router_timeout = IGMP_OTHER_QUERIER_PRESENT_INTERVAL; + + return 0; } int config_parse(const char *file) diff --git a/src/defs.h b/src/defs.h index ba7379e..a151b5e 100644 --- a/src/defs.h +++ b/src/defs.h @@ -146,7 +146,7 @@ extern void netlink_init(void); extern void netlink_exit(void); /* config.c */ -extern void config_init(void); +extern int config_init(void); extern void config_set_ifflag(uint32_t); extern struct ifi *config_iface_iter(int); extern struct ifi *config_iface_add(char *); diff --git a/src/main.c b/src/main.c index 623d8b9..6bea9df 100644 --- a/src/main.c +++ b/src/main.c @@ -67,7 +67,7 @@ static int usage(int code) else snprintf(ipcbuf, sizeof(ipcbuf), _PATH_MCD_SOCK, ident); - printf("Usage: %s [-himnpsv] [-f FILE] [-i NAME] [-l LEVEL] [-p FILE] [-u FILE]\n" + printf("Usage: %s [-hnstv] [-f FILE] [-i NAME] [-l LEVEL] [-p FILE] [-u FILE]\n" "\n" " -f, --config=FILE Default filename derived from -i: %s\n" " -h, --help Show this help text\n" @@ -76,6 +76,7 @@ static int usage(int code) " -n, --foreground Run in foreground, do not detach from controlling terminal\n" " -p, --pidfile=FILE Default pidfile, derived from -i: %s\n" " -s, --syslog Log to syslog, default unless running in --foreground\n" + " -t, --check-config Check the configuration file and exit\n" " -u, --ipc=FILE UNIX domain socket, default from -i: %s\n" " -v, --version Show program version\n", prognm, config_file, nm, pidfn, sockfn); @@ -107,17 +108,19 @@ int main(int argc, char *argv[]) { "foreground", 0, 0, 'n' }, { "pidfile", 1, 0, 'p' }, { "syslog", 0, 0, 's' }, + { "check-config", 0, 0, 't' }, { "ipc", 1, 0, 'u' }, { "version", 0, 0, 'v' }, { NULL, 0, 0, 0 } }; int foreground = 0; + int check = 0; int ch; prognm = ident = progname(argv[0]); sock_file = getenv("MCD_SOCK"); - while ((ch = getopt_long(argc, argv, "f:hi:l:np:su:v", long_options, NULL)) != EOF) { + while ((ch = getopt_long(argc, argv, "f:hi:l:np:stu:v", long_options, NULL)) != EOF) { switch (ch) { case 'f': config_file = strdup(optarg); @@ -156,6 +159,10 @@ int main(int argc, char *argv[]) use_syslog++; break; + case 't': /* --check-config */ + check = 1; + break; + case 'u': sock_file = strdup(optarg); break; @@ -169,6 +176,17 @@ int main(int argc, char *argv[]) } } + log_init(ident, check ? 0 : use_syslog); + compose_paths(); + + if (check) { + if (config_init()) + return EX_NOINPUT; + + note("config file %s is ok", config_file); + return 0; + } + if (geteuid() != 0) { errx("must be root"); exit(1); @@ -210,13 +228,7 @@ int main(int argc, char *argv[]) } else setlinebuf(stderr); - /* - * Setup logging - */ - log_init(ident, use_syslog); - dbg("%s starting", versionstring); - - compose_paths(); + dbg("%s starting, config %s", versionstring, config_file); pev_init(); sock_init(); diff --git a/test/Makefile.am b/test/Makefile.am index afad882..897b3d8 100644 --- a/test/Makefile.am +++ b/test/Makefile.am @@ -1,10 +1,11 @@ -EXTRA_DIST = lib.sh basic.sh sleepy.sh two.sh ipc.sh ipc-errors.sh ipc-disconnect.sh router-timeout.sh late.sh router-ports.sh static-group.sh mld.sh bridge-names.sh mctl-help.sh +EXTRA_DIST = lib.sh config-check.sh basic.sh sleepy.sh two.sh ipc.sh ipc-errors.sh ipc-disconnect.sh router-timeout.sh late.sh router-ports.sh static-group.sh mld.sh bridge-names.sh mctl-help.sh CLEANFILES = *~ *.trs *.log TEST_EXTENSIONS = .sh TESTS_ENVIRONMENT = unshare -mrun --map-auto -TESTS = sleepy.sh +TESTS = config-check.sh +TESTS += sleepy.sh TESTS += basic.sh TESTS += ipc.sh TESTS += ipc-errors.sh diff --git a/test/config-check.sh b/test/config-check.sh new file mode 100755 index 0000000..56a3f5f --- /dev/null +++ b/test/config-check.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# Verifies mcd -t: checks the configuration file and exits, 0 when +# fine, otherwise non-zero with the error as FILE:LINE: message + +# shellcheck source=/dev/null +. "$(dirname "$0")/lib.sh" + +CONF=/tmp/$NM/config +mkdir -p "/tmp/$NM" + +# One row per line: description | config (';' between lines) | +# exit code | expected text in the output +print "Checking configs ..." +fail=0 +while IFS='|' read -r desc conf expect text; do + if [ "$conf" = "MISSING" ]; then + rm -f "$CONF" + else + printf '%s\n' "$conf" | tr ';' '\n' > "$CONF" + fi + + ../src/mcd -t -f "$CONF" > "/tmp/$NM/out" 2>&1 + rc=$? + + if [ "$rc" -ne "$expect" ]; then + echo "FAIL: $desc: exit $rc, expected $expect" + cat "/tmp/$NM/out" + fail=1 + elif ! grep -qF "$text" "/tmp/$NM/out"; then + echo "FAIL: $desc: output lacks '$text'" + cat "/tmp/$NM/out" + fail=1 + else + echo "ok: $desc" + fi +done < Date: Tue, 20 Oct 2026 18:50:05 +0200 Subject: [PATCH 02/13] Add shell completion for mctl, bash and zsh mctl takes its commands from the running daemon's help reply, so a completion script with a list of its own would drift from mcd. New hidden option, mctl -C WORD..., prints the candidates for the last word, which may be partial, from that same list, one per line, with the command's description after a tab when the word completes it. Nothing is printed when no daemon runs. The bash and zsh scripts build on it and hand mctl the line as typed, so -i NAME and -u FILE reach the daemon the command will. zsh shows the descriptions. Installed by make install. --with-bash-completion-dir=DIR and --with-zsh-completion-dir=DIR override the DATADIR defaults, or skip with =no. Signed-off-by: Joachim Wiberg --- ChangeLog.md | 4 ++ Makefile.am | 9 ++++ README.md | 1 + completions/bash/mctl | 36 +++++++++++++++ completions/zsh/_mctl | 34 ++++++++++++++ configure.ac | 17 +++++++ man/mctl.8 | 15 ++++++ src/mctl.c | 75 +++++++++++++++++++++++++++++- test/Makefile.am | 3 +- test/completion.sh | 104 ++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 completions/bash/mctl create mode 100644 completions/zsh/_mctl create mode 100755 test/completion.sh diff --git a/ChangeLog.md b/ChangeLog.md index a0ecf58..43fce2a 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -10,6 +10,10 @@ All relevant, user visible, changes are documented in this file. - New `mcd -t` option to check a configuration file and exit, like `nginx -t`. Errors are reported as `FILE:LINE: message`, and it runs as a normal user + - Shell completion for `mctl`, bash and zsh. The commands come from + the running daemon, via the new `mctl -C` option. Installed with + `make install`, see `--with-bash-completion-dir` and + `--with-zsh-completion-dir` ### Fixes - Fix `mctl show router-ports` and `show igmp json` missing the router diff --git a/Makefile.am b/Makefile.am index 01c8af8..cf01822 100644 --- a/Makefile.am +++ b/Makefile.am @@ -1,5 +1,6 @@ doc_DATA = README.md ChangeLog.md LICENSE mcd.conf EXTRA_DIST = README.md ChangeLog.md LICENSE mcd.conf doc/overview.svg doc/querier-timeline.svg doc/why.svg +EXTRA_DIST += completions/bash/mctl completions/zsh/_mctl DISTCLEANFILES = *~ DEADJOE semantic.cache *.gdb *.elf core core.* *.d SUBDIRS = man src @@ -7,6 +8,14 @@ if ENABLE_TEST SUBDIRS += test endif +if BASHCOMP +bashcomp_DATA = completions/bash/mctl +endif + +if ZSHCOMP +zshcomp_DATA = completions/zsh/_mctl +endif + ## Check if tagged in git release-hook: @if [ "x`git tag -l v$(PACKAGE_VERSION)|grep $(PACKAGE_VERSION)`" = "x" ]; then \ diff --git a/README.md b/README.md index 13ea2f1..7699e92 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,7 @@ Options worth knowing: |------------------------|------------------------------------------------------------| | `--with-group=NAME` | Group owner of `/run/mcd.sock`, default `root`. Use e.g. `wheel` to let admins run `mctl` without `sudo` | | `--enable-test` | Build the test suite, see [Testing](#testing) | +| `--with-bash-completion-dir=DIR`, `--with-zsh-completion-dir=DIR` | Where to install the `mctl` completion scripts, default under `DATADIR`, `no` to skip | | `--prefix=`, `--sysconfdir=` | The usual, defaults are `/usr/local` and `/etc` | Configure prints a summary at the end, check it before running `make`. diff --git a/completions/bash/mctl b/completions/bash/mctl new file mode 100644 index 0000000..156882d --- /dev/null +++ b/completions/bash/mctl @@ -0,0 +1,36 @@ +# bash completion for mctl(8) +# +# The commands come from the running mcd, via mctl -C, so there is +# nothing to complete when it is down, apart from the options. + +_mctl() +{ + local cur=${COMP_WORDS[COMP_CWORD]} + local prev=${COMP_WORDS[COMP_CWORD-1]} + local word + + case $prev in + -u|--ipc) + COMPREPLY=($(compgen -f -- "$cur")) + return + ;; + -i|--ident) + return + ;; + esac + + if [[ $cur == -* ]]; then + COMPREPLY=($(compgen -W '-h --help -i --ident -m --monitor -p --plain + -t --no-heading -u --ipc -v --version' -- "$cur")) + return + fi + + # All words so far, options included, so -i NAME and -u FILE reach + # the same daemon the command will. The last word is the partial. + COMPREPLY=() + while IFS=$'\t' read -r word _; do + COMPREPLY+=("$word") + done < <(mctl -C "${COMP_WORDS[@]:1:COMP_CWORD}" 2>/dev/null) +} + +complete -F _mctl mctl diff --git a/completions/zsh/_mctl b/completions/zsh/_mctl new file mode 100644 index 0000000..1b96de8 --- /dev/null +++ b/completions/zsh/_mctl @@ -0,0 +1,34 @@ +#compdef mctl +# +# zsh completion for mctl(8). The commands come from the running +# mcd, via mctl -C, with their descriptions. + +_mctl() { + # The line as typed, _arguments rewrites $words for the command + local -a typed=("${words[@]}") cands + local cur=$CURRENT line + + _arguments -s \ + '(-h --help)'{-h,--help}'[Show usage and the available commands]' \ + '(-i --ident)'{-i,--ident}'[Talk to the mcd started with this identity]:name' \ + '(-m --monitor)'{-m,--monitor}'[Run the command every two seconds]' \ + '(-p --plain)'{-p,--plain}'[Plain table headings]' \ + '(-t --no-heading)'{-t,--no-heading}'[Skip table headings]' \ + '(-u --ipc)'{-u,--ipc}'[IPC socket]:file:_files' \ + '(-v --version)'{-v,--version}'[Show version and exit]' \ + '*::command:->command' && return + + case $state in + command) + # All words so far, options included, so -i NAME and -u FILE + # reach the same daemon the command will. worddesc + # becomes word:desc for _describe + for line in ${(f)"$(mctl -C "${typed[@]:1:cur-1}" 2>/dev/null)"}; do + cands+=("${line/$'\t'/:}") + done + _describe -t commands 'mctl command' cands + ;; + esac +} + +_mctl "$@" diff --git a/configure.ac b/configure.ac index 3913f3c..c348c59 100644 --- a/configure.ac +++ b/configure.ac @@ -65,6 +65,19 @@ AC_ARG_WITH(group, AS_HELP_STRING([--with-group=NAME], [Group for /run/mcd.sock (mctl), default: root]), [group=$withval], [group=root]) +AC_ARG_WITH(bash-completion-dir, + AS_HELP_STRING([--with-bash-completion-dir=DIR], [Install mctl bash completion in DIR, default: DATADIR/bash-completion/completions, no to skip]), + [bashcompdir=$withval], [bashcompdir=yes]) + +AC_ARG_WITH(zsh-completion-dir, + AS_HELP_STRING([--with-zsh-completion-dir=DIR], [Install mctl zsh completion in DIR, default: DATADIR/zsh/site-functions, no to skip]), + [zshcompdir=$withval], [zshcompdir=yes]) + +AS_IF([test "x$bashcompdir" = "xyes"], [bashcompdir='$(datadir)/bash-completion/completions']) +AS_IF([test "x$zshcompdir" = "xyes"], [zshcompdir='$(datadir)/zsh/site-functions']) +AC_SUBST(bashcompdir) +AC_SUBST(zshcompdir) + # Create config.h from selected features and fallback defautls AS_IF([test "x$with_group" != "xno"], [ AS_IF([test "x$group" = "xyes"], [ @@ -74,6 +87,8 @@ AC_DEFINE_UNQUOTED(DEFGROUP, "$group", [For /run/finit/socket]) AM_CONDITIONAL(BSD, [test "x$ac_cv_header_net_if_dl_h" = "xyes"]) AM_CONDITIONAL(LINUX, [test "x$ac_cv_header_linux_netlink_h" = "xyes"]) AM_CONDITIONAL(ENABLE_TEST, [test "x$enable_test" != "xno"]) +AM_CONDITIONAL(BASHCOMP, [test "x$bashcompdir" != "xno"]) +AM_CONDITIONAL(ZSHCOMP, [test "x$zshcompdir" != "xno"]) # Workaround for as-of-yet unreleased runstatedir support, planned for # autoconf 2.70, which some major distros have backported. @@ -95,6 +110,8 @@ cat <opt) { strlcat(buf, " [", sizeof(buf)); strlcat(buf, c->opt, sizeof(buf)); + strlcat(buf, "]", sizeof(buf)); } printf(" %-25s %s\n", buf, c->desc); @@ -587,6 +594,61 @@ static int version(void) return 0; } +/* + * Shell completion, mctl -C WORD...: the words typed so far, the last + * one partial. Prints the candidates for it, one per line, with the + * command's description after a tab when the word completes it. + */ +static int complete(int argc, char *argv[]) +{ + const char *seen[MAXARGS * 4]; + int n = argc ? argc - 1 : 0; /* position of the partial word */ + const char *cur = argc ? argv[n] : ""; + size_t len = strlen(cur); + int nseen = 0; + struct cmd *c; + + if (ipc_fetch()) + return 1; + + TAILQ_FOREACH(c, &cmds, link) { + const char *word; + int i; + + for (i = 0; i < n && i < c->argc; i++) { + if (!string_match(argv[i], c->argv[i])) + break; + } + if (i < n) + continue; + + if (n < c->argc) + word = c->argv[n]; + else if (n == c->argc && c->opt) + word = c->opt; + else + continue; + + if (strncasecmp(cur, word, len)) + continue; + + for (i = 0; i < nseen; i++) { + if (!strcmp(seen[i], word)) + break; + } + if (i < nseen || nseen >= (int)NELEMS(seen)) + continue; + seen[nseen++] = word; + + if (n + 1 == c->argc && c->desc) + printf("%s\t%s\n", word, c->desc); + else + puts(word); + } + + return 0; +} + static int cmd(int argc, char *argv[]) { char buf[768]; @@ -624,6 +686,7 @@ static int cmd(int argc, char *argv[]) int main(int argc, char *argv[]) { struct option long_options[] = { + { "complete", 0, NULL, 'C' }, { "debug", 0, NULL, 'd' }, { "help", 0, NULL, 'h' }, { "ident", 1, NULL, 'i' }, @@ -635,12 +698,17 @@ int main(int argc, char *argv[]) { NULL, 0, NULL, 0 } }; int monitor = 0; + int compl = 0; int c, rc; sock_file = getenv("MCD_SOCK"); - while ((c = getopt_long(argc, argv, "dh?i:mptu:v", long_options, NULL)) != EOF) { + while ((c = getopt_long(argc, argv, "Cdh?i:mptu:v", long_options, NULL)) != EOF) { switch(c) { + case 'C': /* --complete, for the shell completion scripts */ + compl = 1; + break; + case 'd': debug = 1; break; @@ -680,6 +748,9 @@ int main(int argc, char *argv[]) } } + if (compl) + return complete(argc - optind, &argv[optind]); + do { if (monitor) { time_t now; diff --git a/test/Makefile.am b/test/Makefile.am index 897b3d8..7404895 100644 --- a/test/Makefile.am +++ b/test/Makefile.am @@ -1,4 +1,4 @@ -EXTRA_DIST = lib.sh config-check.sh basic.sh sleepy.sh two.sh ipc.sh ipc-errors.sh ipc-disconnect.sh router-timeout.sh late.sh router-ports.sh static-group.sh mld.sh bridge-names.sh mctl-help.sh +EXTRA_DIST = lib.sh config-check.sh completion.sh basic.sh sleepy.sh two.sh ipc.sh ipc-errors.sh ipc-disconnect.sh router-timeout.sh late.sh router-ports.sh static-group.sh mld.sh bridge-names.sh mctl-help.sh CLEANFILES = *~ *.trs *.log TEST_EXTENSIONS = .sh @@ -18,3 +18,4 @@ TESTS += static-group.sh TESTS += mld.sh TESTS += bridge-names.sh TESTS += mctl-help.sh +TESTS += completion.sh diff --git a/test/completion.sh b/test/completion.sh new file mode 100755 index 0000000..61160f3 --- /dev/null +++ b/test/completion.sh @@ -0,0 +1,104 @@ +#!/bin/sh +# Verifies shell completion for mctl: mctl -C WORD... prints the +# candidates for the last, partial, word from the running mcd's +# command list, and the bash completion function builds on it + +# shellcheck source=/dev/null +. "$(dirname "$0")/lib.sh" + +print "Check deps ..." +check_dep socat +check_dep bash + +print "Creating world ..." +addif eth0 192.168.0.1/24 + +print "Creating config ..." +cat < "/tmp/$NM/config" +iface eth0 enable igmpv3 +EOF +cat "/tmp/$NM/config" + +print "Starting mcd ..." +SOCK=/tmp/$NM/sock +../src/mcd -f "/tmp/$NM/config" -p "/tmp/$NM/pid" -l debug -n -u "$SOCK" & +echo $! >> "/tmp/$NM/PIDs" +tenacious 10 test -S "$SOCK" + +PATH=$(pwd)/../src:$PATH +export PATH + +# Candidates as one sorted line +line() +{ + # shellcheck disable=SC2046 + echo $(cut -f1 | LC_ALL=C sort) +} + +# The bash function, driven without a terminal +bashcomp() +{ + bash -c ' + . ../completions/bash/mctl + COMP_WORDS=("$@"); COMP_CWORD=$(($# - 1)) + _mctl + printf "%s\n" "${COMPREPLY[@]}"' bash "$@" +} + +fail=0 +check() +{ + if [ "$2" = "$3" ]; then + echo "ok: $1" + else + echo "FAIL: $1: got '$3', expected '$2'" + fail=1 + fi +} + +# One row per line: description | words so far, the last one partial, +# '' for an empty one | expected candidates, sorted +print "Checking mctl -C ..." +while IFS='|' read -r desc words expect; do + eval "set -- $words" + check "$desc" "$expect" "$(mctl -u "$SOCK" -C "$@" 2>&1 | line)" +done <