]> git.ipfire.org Git - thirdparty/freeradius-server.git/commitdiff
Add radconf2json, radmod2json and radjson2conf utilities
authorArran Cudbard-Bell <a.cudbardb@freeradius.org>
Fri, 15 May 2026 04:35:14 +0000 (22:35 -0600)
committerArran Cudbard-Bell <a.cudbardb@freeradius.org>
Fri, 15 May 2026 16:44:10 +0000 (10:44 -0600)
Three JSON-bridge utilities used by the v3-to-v4 converter pipeline:

  * radconf2json: dump a parsed server config tree as JSON.  Opt-in
    comment preservation lets `# ...` lines round-trip through the
    JSON so downstream tooling can edit and re-emit the source.

  * radmod2json:  dump module conf_parser_t + call_env_parser_t
                  schemas as JSON via dl_module_alloc (proper module
                  loader, magic verified, init callbacks honoured).
                  Each dlopen+dump is fork-sandboxed so a misbehaving
                  module can't take the whole run down.  Function
                  pointers are resolved to source symbols via
                  dladdr.

  * radjson2conf: parse the JSON back into a CONF_SECTION tree and
                  write it out as a v4 .conf file using
                  cf_section_write / cf_section_write_children.  -r
                  emits each child of the synthetic root at file
                  scope so a fragment can be rendered without an
                  outer wrapper.

JSON shape mirrors the C struct field names verbatim (FR_TYPE_STRING,
T_BARE_WORD, CONF_FLAG_REQUIRED, ...) so converter rule files can
grep against the source tree.

Symbolic-name <-> token lookups use v4's existing table machinery
(fr_tokens / fr_tokens_table for operators, fr_table_num_indexed_bit_pos_t
for CONF_FLAG_* / CALL_ENV_FLAG_* masks, fr_table_ptr_sorted_t for the
JSON `type` -> builder dispatch); no hand-rolled switch cascades.

src/bin/all.mk
src/bin/radconf2json.c [new file with mode: 0644]
src/bin/radconf2json.mk [new file with mode: 0644]
src/bin/radjson2conf.c [new file with mode: 0644]
src/bin/radjson2conf.mk [new file with mode: 0644]
src/bin/radmod2json.c [new file with mode: 0644]
src/bin/radmod2json.mk [new file with mode: 0644]

index 6d3e7ccfbb80c3da2f7f458716917d2c81a3ee93..83b25c4feba25f4bb4c6697a64695ed3d9242747 100644 (file)
@@ -1,6 +1,9 @@
 SUBMAKEFILES := \
     radclient.mk \
     radclient-ng.mk \
+    radconf2json.mk \
+    radjson2conf.mk \
+    radmod2json.mk \
     radict.mk \
     radiusd.mk \
     radlock.mk \
diff --git a/src/bin/radconf2json.c b/src/bin/radconf2json.c
new file mode 100644 (file)
index 0000000..d277dab
--- /dev/null
@@ -0,0 +1,327 @@
+/*
+ * radconf2json.c   Dump a parsed FreeRADIUS v4 configuration tree as JSON.
+ *
+ *   This program is free software; you can redistribute it and/or modify
+ *   it under the terms of the GNU General Public License as published by
+ *   the Free Software Foundation; either version 2 of the License, or
+ *   (at your option) any later version.
+ *
+ *   This program is distributed in the hope that it will be useful,
+ *   but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *   GNU General Public License for more details.
+ *
+ *   You should have received a copy of the GNU General Public License
+ *   along with this program; if not, write to the Free Software
+ *   Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA
+ *
+ * Copyright (C) 2026 Arran Cudbard-Bell <a.cudbardb@freeradius.org>
+ */
+
+RCSID("$Id$")
+
+#include <freeradius-devel/server/base.h>
+#include <freeradius-devel/server/cf_file.h>
+#include <freeradius-devel/server/cf_util.h>
+#include <freeradius-devel/server/main_config.h>
+#include <freeradius-devel/server/module.h>
+#include <freeradius-devel/server/module_rlm.h>
+#include <freeradius-devel/server/virtual_servers.h>
+#include <freeradius-devel/unlang/base.h>
+#include <freeradius-devel/util/atexit.h>
+#include <freeradius-devel/util/debug.h>
+#include <freeradius-devel/util/syserror.h>
+
+#ifdef WITH_TLS
+#  include <freeradius-devel/tls/base.h>
+#  include <freeradius-devel/tls/version.h>
+#endif
+
+#include <json-c/json.h>
+
+#ifdef HAVE_GETOPT_H
+#  include <getopt.h>
+#endif
+
+#define EXIT_WITH_FAILURE             \
+       do {                          \
+               rcode = EXIT_FAILURE; \
+               goto finish;          \
+       } while (0)
+
+char const *radiusd_version = RADIUSD_VERSION_BUILD("radconf2json");
+
+/*
+ *     Symbolic names for the quote tokens.  v4's own `fr_tokens[]` table
+ *     gives "<BARE-WORD>" / "<\"STRING\">" / etc. - good for human
+ *     error messages, not useful when the rule layer wants to grep the
+ *     JSON for `T_BARE_WORD`.  Indexed by `fr_token_t` for O(1) lookup;
+ *     an unset entry (or out-of-range token) falls back to `T_BARE_WORD`,
+ *     the same default the parser uses when no quoting was recorded.
+ */
+static char const *const quote_names[T_TOKEN_LAST] = {
+       [T_BARE_WORD]             = "T_BARE_WORD",
+       [T_DOUBLE_QUOTED_STRING]  = "T_DOUBLE_QUOTED_STRING",
+       [T_SINGLE_QUOTED_STRING]  = "T_SINGLE_QUOTED_STRING",
+       [T_BACK_QUOTED_STRING]    = "T_BACK_QUOTED_STRING",
+       [T_SOLIDUS_QUOTED_STRING] = "T_SOLIDUS_QUOTED_STRING",
+};
+
+static inline char const *quote_name(fr_token_t t)
+{
+       char const *s;
+
+       if ((unsigned int)t >= T_TOKEN_LAST) return "T_BARE_WORD";
+       s = quote_names[t];
+       if (!s) return "T_BARE_WORD";
+       return s;
+}
+
+/*
+ *     v4 already maintains an `fr_token_t -> operator string` table
+ *     (fr_tokens[] in src/lib/util/token.c).  Use it instead of
+ *     rolling our own.
+ */
+static inline char const *op_name(fr_token_t op)
+{
+       char const *s;
+
+       if ((unsigned int)op >= T_TOKEN_LAST) return "?";
+       s = fr_tokens[op];
+       if (!s) return "?";
+       return s;
+}
+
+static struct json_object *build_location(char const *filename, int lineno)
+{
+       struct json_object *loc;
+
+       if (!filename && lineno == 0) return NULL;
+
+       loc = json_object_new_object();
+       json_object_object_add(loc, "filename", filename ? json_object_new_string(filename) : NULL);
+       json_object_object_add(loc, "lineno", json_object_new_int(lineno));
+       return loc;
+}
+
+static struct json_object *build_comment(CONF_COMMENT const *c)
+{
+       struct json_object *o        = json_object_new_object();
+       char const         *filename = cf_filename(c);
+       int                 lineno   = cf_lineno(c);
+
+       json_object_object_add(o, "type", json_object_new_string("comment"));
+       json_object_object_add(o, "text", cf_comment_text(c) ? json_object_new_string(cf_comment_text(c)) : NULL);
+       json_object_object_add(o, "location", build_location(filename, lineno));
+       return o;
+}
+
+static struct json_object *build_pair(CONF_PAIR const *cp)
+{
+       struct json_object *o     = json_object_new_object();
+       char const         *value = cf_pair_value(cp);
+
+       json_object_object_add(o, "type", json_object_new_string("pair"));
+       json_object_object_add(o, "attr", cf_pair_attr(cp) ? json_object_new_string(cf_pair_attr(cp)) : NULL);
+       json_object_object_add(o, "lhs_quote", json_object_new_string(quote_name(cf_pair_attr_quote(cp))));
+       json_object_object_add(o, "op", json_object_new_string(op_name(cf_pair_operator(cp))));
+       json_object_object_add(o, "value", value ? json_object_new_string(value) : NULL);
+       json_object_object_add(o, "rhs_quote", json_object_new_string(quote_name(cf_pair_value_quote(cp))));
+       json_object_object_add(o, "location", build_location(cf_filename(cp), cf_lineno(cp)));
+
+       return o;
+}
+
+static struct json_object *build_section(CONF_SECTION const *cs)
+{
+       struct json_object *o        = json_object_new_object();
+       struct json_object *children = json_object_new_array();
+       char const         *name2    = cf_section_name2(cs);
+
+       json_object_object_add(o, "type", json_object_new_string("section"));
+       json_object_object_add(o, "name1", cf_section_name1(cs) ? json_object_new_string(cf_section_name1(cs)) : NULL);
+       json_object_object_add(o, "name2", name2 ? json_object_new_string(name2) : NULL);
+       json_object_object_add(o, "name2_quote", json_object_new_string(quote_name(cf_section_name2_quote(cs))));
+       json_object_object_add(o, "location", build_location(cf_filename(cs), cf_lineno(cs)));
+
+       cf_item_foreach(cs, ci)
+       {
+               if (cf_item_is_section(ci)) {
+                       json_object_array_add(children, build_section(cf_item_to_section(UNCONST(CONF_ITEM *, ci))));
+               } else if (cf_item_is_pair(ci)) {
+                       json_object_array_add(children, build_pair(cf_item_to_pair(UNCONST(CONF_ITEM *, ci))));
+               } else if (cf_item_is_comment(ci)) {
+                       json_object_array_add(children, build_comment(cf_item_to_comment(UNCONST(CONF_ITEM *, ci))));
+               }
+       }
+
+       json_object_object_add(o, "children", children);
+       return o;
+}
+
+static NEVER_RETURNS void usage(int rcode)
+{
+       FILE *fp = (rcode == 0) ? stdout : stderr;
+
+       fprintf(fp, "Usage: radconf2json [options]\n"
+                   "  -d <raddb>    Set raddb directory.\n"
+                   "  -D <dict>     Set dictionary directory.\n"
+                   "  -n <name>     Read <name>.conf instead of radiusd.conf.\n"
+                   "  -o <file>     Write JSON to <file> (default stdout).\n"
+                   "  -x            Enable debug output (repeatable).\n"
+                   "  -X            Verbose debug output.\n"
+                   "  -h            This help.\n");
+       exit(rcode);
+}
+
+int main(int argc, char *argv[])
+{
+       int                 c;
+       int                 rcode       = EXIT_SUCCESS;
+       char const         *output_file = NULL;
+       TALLOC_CTX         *autofree;
+       main_config_t      *config = NULL;
+       fr_dict_t          *dict   = NULL;
+       struct json_object *root   = NULL;
+
+       autofree = talloc_autofree_context();
+
+       config = main_config_alloc(autofree);
+       if (!config) {
+               fr_perror("radconf2json");
+               fr_exit_now(EXIT_FAILURE);
+       }
+
+       main_config_name_set_default(config, "radiusd", false);
+
+       fr_talloc_fault_setup();
+       fr_debug_lvl = 0;
+       fr_time_start();
+
+       default_log.dst         = L_DST_STDERR;
+       default_log.fd          = STDERR_FILENO;
+       default_log.print_level = true;
+
+       while ((c = getopt(argc, argv, "d:D:hn:o:xX")) != -1) {
+               switch (c) {
+               case 'd':
+                       main_config_confdir_set(config, optarg);
+                       break;
+
+               case 'D':
+                       main_config_dict_dir_set(config, optarg);
+                       break;
+
+               case 'h':
+                       usage(EXIT_SUCCESS);
+
+               case 'n':
+                       config->name = optarg;
+                       break;
+
+               case 'o':
+                       output_file = optarg;
+                       break;
+
+               case 'X':
+                       fr_debug_lvl += 2;
+                       break;
+
+               case 'x':
+                       fr_debug_lvl++;
+                       break;
+
+               default:
+                       usage(EXIT_FAILURE);
+               }
+       }
+
+#ifdef WITH_TLS
+       if (fr_openssl_init() < 0) {
+               fr_perror("%s", config->name);
+               EXIT_WITH_FAILURE;
+       }
+#endif
+
+       if (fr_check_lib_magic(RADIUSD_MAGIC_NUMBER) < 0) {
+               fr_perror("%s", config->name);
+               EXIT_WITH_FAILURE;
+       }
+
+       /*
+        *      Opt in to comment preservation - the runtime server parser
+        *      drops them, but we want a faithful round-trip through JSON.
+        */
+       cf_preserve_comments_set(true);
+
+       modules_init(config->lib_dir);
+
+       if (!fr_dict_global_ctx_init(NULL, true, config->dict_dir)) {
+               fr_perror("%s", config->name);
+               EXIT_WITH_FAILURE;
+       }
+
+       if (fr_dict_internal_afrom_file(&dict, FR_DICTIONARY_INTERNAL_DIR, __FILE__) < 0) {
+               fr_perror("%s", config->name);
+               EXIT_WITH_FAILURE;
+       }
+
+#ifdef WITH_TLS
+       if (fr_tls_dict_init() < 0) EXIT_WITH_FAILURE;
+#endif
+
+       if (fr_dict_read(dict, config->confdir, FR_DICTIONARY_FILE) == -1) {
+               PERROR("Failed to initialize the dictionaries");
+               EXIT_WITH_FAILURE;
+       }
+
+       if (request_global_init() < 0) {
+               fr_perror("%s", config->name);
+               EXIT_WITH_FAILURE;
+       }
+
+       if (unlang_global_init() < 0) {
+               fr_perror("%s", config->name);
+               EXIT_WITH_FAILURE;
+       }
+
+       if (modules_rlm_init() < 0) {
+               fr_perror("%s", config->name);
+               EXIT_WITH_FAILURE;
+       }
+
+       if (virtual_servers_init() < 0) {
+               fr_perror("%s", config->name);
+               EXIT_WITH_FAILURE;
+       }
+
+       if (main_config_init(config) < 0) {
+               EXIT_WITH_FAILURE;
+       }
+
+       root = build_section(config->root_cs);
+
+       {
+               char const *json_str =
+                       json_object_to_json_string_ext(root, JSON_C_TO_STRING_PRETTY | JSON_C_TO_STRING_NOSLASHESCAPE);
+
+               if (output_file) {
+                       FILE *out = fopen(output_file, "w");
+                       if (!out) {
+                               fprintf(stderr, "Failed opening %s: %s\n", output_file, fr_syserror(errno));
+                               EXIT_WITH_FAILURE;
+                       }
+                       fputs(json_str, out);
+                       fputc('\n', out);
+                       fclose(out);
+               } else {
+                       fputs(json_str, stdout);
+                       fputc('\n', stdout);
+               }
+       }
+
+finish:
+       if (root) json_object_put(root);
+       main_config_free(&config);
+       return rcode;
+}
diff --git a/src/bin/radconf2json.mk b/src/bin/radconf2json.mk
new file mode 100644 (file)
index 0000000..a605cd9
--- /dev/null
@@ -0,0 +1,24 @@
+#
+#  Only build radconf2json if libfreeradius-json (the json-c wrapper)
+#  was configured.  Probe TARGETNAME via the lib's all.mk - it's empty
+#  when json-c wasn't found at autoconf time.  Reset TARGET so a stale
+#  value from the previous .mk in the pipeline doesn't sneak through.
+#
+TARGETNAME     :=
+-include $(top_builddir)/src/lib/json/all.mk
+TARGET         :=
+
+ifneq "$(TARGETNAME)" ""
+TARGETNAME     := radconf2json
+TARGET         := $(TARGETNAME)$(E)
+endif
+
+SOURCES                := radconf2json.c
+
+SRC_CFLAGS     += -I$(top_builddir)/src/lib/json/
+TGT_LDLIBS     += $(LIBS) $(LCRYPT)
+TGT_PREREQS    := $(LIBFREERADIUS_SERVER) libfreeradius-io$(L) libfreeradius-json$(L)
+
+ifneq ($(MAKECMDGOALS),scan)
+SRC_CFLAGS     += -DBUILT_WITH_CPPFLAGS=\"$(CPPFLAGS)\" -DBUILT_WITH_CFLAGS=\"$(CFLAGS)\" -DBUILT_WITH_LDFLAGS=\"$(LDFLAGS)\" -DBUILT_WITH_LIBS=\"$(LIBS)\"
+endif
diff --git a/src/bin/radjson2conf.c b/src/bin/radjson2conf.c
new file mode 100644 (file)
index 0000000..6a0c979
--- /dev/null
@@ -0,0 +1,392 @@
+/*
+ * radjson2conf.c  Render a FreeRADIUS v4 config JSON tree back to a .conf file.
+ *
+ *   This program is free software; you can redistribute it and/or modify
+ *   it under the terms of the GNU General Public License as published by
+ *   the Free Software Foundation; either version 2 of the License, or
+ *   (at your option) any later version.
+ *
+ *   This program is distributed in the hope that it will be useful,
+ *   but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *   GNU General Public License for more details.
+ *
+ *   You should have received a copy of the GNU General Public License
+ *   along with this program; if not, write to the Free Software
+ *   Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA
+ *
+ * Copyright (C) 2026 Arran Cudbard-Bell <a.cudbardb@freeradius.org>
+ */
+
+RCSID("$Id$")
+
+#include <freeradius-devel/server/base.h>
+#include <freeradius-devel/server/cf_file.h>
+#include <freeradius-devel/server/cf_util.h>
+#include <freeradius-devel/util/atexit.h>
+#include <freeradius-devel/util/debug.h>
+#include <freeradius-devel/util/syserror.h>
+
+#include <json-c/json.h>
+
+#ifdef HAVE_GETOPT_H
+#  include <getopt.h>
+#endif
+
+char const *radiusd_version = RADIUSD_VERSION_BUILD("radjson2conf");
+
+/*
+ *     Symbolic quote-token names (the strings radconf2json emits).  A
+ *     small sorted table - fr_table_value_by_str is happy with five
+ *     entries and lets us inherit the rest of v4's table machinery.
+ */
+static fr_table_num_sorted_t const quote_names_table[] = {
+       { L("T_BACK_QUOTED_STRING"), T_BACK_QUOTED_STRING },
+       { L("T_BARE_WORD"), T_BARE_WORD },
+       { L("T_DOUBLE_QUOTED_STRING"), T_DOUBLE_QUOTED_STRING },
+       { L("T_SINGLE_QUOTED_STRING"), T_SINGLE_QUOTED_STRING },
+       { L("T_SOLIDUS_QUOTED_STRING"), T_SOLIDUS_QUOTED_STRING },
+};
+static size_t quote_names_table_len = NUM_ELEMENTS(quote_names_table);
+
+static inline fr_token_t quote_token(char const *s)
+{
+       if (!s) return T_BARE_WORD;
+       return fr_table_value_by_str(quote_names_table, s, T_BARE_WORD);
+}
+
+/*
+ *     v4's `fr_tokens_table` already maps operator strings to their
+ *     fr_token_t value (it's what the CF parser itself uses).  Reuse
+ *     instead of rolling our own.
+ */
+static inline fr_token_t op_token(char const *s)
+{
+       if (!s) return T_OP_EQ;
+       return fr_table_value_by_str(fr_tokens_table, s, T_OP_EQ);
+}
+
+static char const *json_get_str(struct json_object *o, char const *key)
+{
+       struct json_object *v;
+       if (!json_object_object_get_ex(o, key, &v)) return NULL;
+       if (!v || json_object_is_type(v, json_type_null)) return NULL;
+       return json_object_get_string(v);
+}
+
+static int json_get_int(struct json_object *o, char const *key, int dflt)
+{
+       struct json_object *v;
+       if (!json_object_object_get_ex(o, key, &v)) return dflt;
+       if (!v || json_object_is_type(v, json_type_null)) return dflt;
+       return json_object_get_int(v);
+}
+
+/*
+ *     Pull `location.filename` / `location.lineno` out of a nested object.
+ *     The location object is itself optional - emit nothing if absent.
+ */
+static void json_get_location(struct json_object *item, char const **filename, int *lineno)
+{
+       struct json_object *loc;
+
+       if (!json_object_object_get_ex(item, "location", &loc)) {
+       error:
+               *filename = NULL;
+               *lineno   = 0;
+               return;
+       }
+       if (!loc || json_object_is_type(loc, json_type_null)) goto error;
+
+       *filename = json_get_str(loc, "filename");
+       *lineno   = json_get_int(loc, "lineno", 0);
+}
+
+static int build_item(CONF_SECTION *parent, struct json_object *item);
+
+static int build_pair(CONF_SECTION *parent, struct json_object *item)
+{
+       CONF_PAIR  *cp;
+       char const *attr    = json_get_str(item, "attr");
+       char const *value   = json_get_str(item, "value");
+       char const *op_s    = json_get_str(item, "op");
+       char const *lhs_q_s = json_get_str(item, "lhs_quote");
+       char const *rhs_q_s = json_get_str(item, "rhs_quote");
+       char const *filename;
+       int         lineno;
+
+       json_get_location(item, &filename, &lineno);
+
+       if (!attr) {
+               fprintf(stderr, "pair without attr (line %d)\n", lineno);
+               return -1;
+       }
+
+       cp = cf_pair_alloc(parent, attr, value, op_token(op_s), quote_token(lhs_q_s), quote_token(rhs_q_s));
+       if (!cp) return -1;
+
+       /*
+        *      cf_section_write skips pairs whose filename is NULL or
+        *      starts with '<' (the marker for synthetic items).  Default
+        *      to "converted" when the JSON has no location so converter-
+        *      added pairs survive the round-trip.
+        */
+       cf_filename_set(cp, (filename && filename[0] != '<') ? filename : "converted");
+       cf_lineno_set(cp, lineno > 0 ? lineno : 1);
+
+       return 0;
+}
+
+static int build_section_into(CONF_SECTION *parent, struct json_object *item)
+{
+       CONF_SECTION       *cs;
+       struct json_object *children;
+       char const         *name1 = json_get_str(item, "name1");
+       char const         *name2 = json_get_str(item, "name2");
+       char const         *filename;
+       int                 lineno;
+
+       json_get_location(item, &filename, &lineno);
+
+       if (!name1) {
+               fprintf(stderr, "section without name1 (line %d)\n", lineno);
+               return -1;
+       }
+
+       cs = cf_section_alloc(parent, parent, name1, name2);
+       if (!cs) return -1;
+
+       if (filename) cf_filename_set(cs, filename);
+       if (lineno) cf_lineno_set(cs, lineno);
+
+       if (json_object_object_get_ex(item, "children", &children) && children) {
+               size_t n = json_object_array_length(children);
+               for (size_t i = 0; i < n; i++) {
+                       struct json_object *child = json_object_array_get_idx(children, i);
+                       if (build_item(cs, child) < 0) return -1;
+               }
+       }
+
+       return 0;
+}
+
+static int build_comment(CONF_SECTION *parent, struct json_object *item)
+{
+       CONF_COMMENT *c;
+       char const   *text = json_get_str(item, "text");
+       char const   *filename;
+       int           lineno;
+
+       json_get_location(item, &filename, &lineno);
+
+       c = cf_comment_alloc(parent, text);
+       if (!c) return -1;
+
+       cf_filename_set(c, (filename && filename[0] != '<') ? filename : "converted");
+       cf_lineno_set(c, lineno > 0 ? lineno : 1);
+       return 0;
+}
+
+/*
+ *     JSON `type` -> builder dispatch.  Keep alphabetically sorted so
+ *     the fr_table_value_by_str binary search lookup works.
+ */
+typedef int (*build_fn_t)(CONF_SECTION *parent, struct json_object *item);
+
+static int build_item(CONF_SECTION *parent, struct json_object *item)
+{
+       static fr_table_ptr_sorted_t const item_builders[] = {
+               { L("comment"), build_comment },
+               { L("pair"), build_pair },
+               { L("section"), build_section_into },
+       };
+       static size_t item_builders_len = NUM_ELEMENTS(item_builders);
+
+       char const *type = json_get_str(item, "type");
+       build_fn_t  build;
+
+       if (!type) {
+               fprintf(stderr, "item without type field\n");
+               return -1;
+       }
+
+       build = (build_fn_t)(uintptr_t)fr_table_value_by_str(item_builders, type, NULL);
+       if (!build) {
+               fprintf(stderr, "unknown item type %s\n", type);
+               return -1;
+       }
+       return build(parent, item);
+}
+
+/*
+ *     Build a top-level CONF_SECTION from a JSON object that represents
+ *     the root section.  Returns the allocated CONF_SECTION on success,
+ *     NULL on failure.
+ */
+static CONF_SECTION *build_root_section(TALLOC_CTX *ctx, struct json_object *root)
+{
+       CONF_SECTION       *cs;
+       struct json_object *children;
+       char const         *name1 = json_get_str(root, "name1");
+       char const         *name2 = json_get_str(root, "name2");
+       char const         *filename;
+       int                 lineno;
+
+       json_get_location(root, &filename, &lineno);
+
+       if (!name1) name1 = "main";
+
+       cs = cf_section_alloc(ctx, NULL, name1, name2);
+       if (!cs) return NULL;
+
+       if (filename) cf_filename_set(cs, filename);
+       if (lineno) cf_lineno_set(cs, lineno);
+
+       if (json_object_object_get_ex(root, "children", &children) && children) {
+               size_t n = json_object_array_length(children);
+               for (size_t i = 0; i < n; i++) {
+                       struct json_object *child = json_object_array_get_idx(children, i);
+                       if (build_item(cs, child) < 0) {
+                               talloc_free(cs);
+                               return NULL;
+                       }
+               }
+       }
+
+       return cs;
+}
+
+static NEVER_RETURNS void usage(int rcode)
+{
+       FILE *fp = (rcode == 0) ? stdout : stderr;
+
+       fprintf(fp, "Usage: radjson2conf [options]\n"
+                   "  -i <file>     Read JSON from <file> (default stdin).\n"
+                   "  -o <file>     Write conf to <file> (default stdout).\n"
+                   "  -r            Strip the root section wrapper, emit children at file scope.\n"
+                   "                Use this to produce a radiusd.conf-style top-level file.\n"
+                   "  -h            This help.\n");
+       exit(rcode);
+}
+
+int main(int argc, char *argv[])
+{
+       int                 c;
+       char const         *input_file  = NULL;
+       char const         *output_file = NULL;
+       bool                strip_root  = false;
+       FILE               *out;
+       TALLOC_CTX         *autofree;
+       struct json_object *root_json;
+       CONF_SECTION       *root_cs;
+       int                 rcode = EXIT_SUCCESS;
+
+       autofree = talloc_autofree_context();
+
+       /*
+        *      We're rebuilding a CF tree out of JSON, fragment by fragment,
+        *      to emit it back to disk.  Resolving `${var}` references would
+        *      either fail (the variable lives in a sibling fragment, not in
+        *      the one we're parsing) or silently bake values in - both
+        *      wrong for the round-trip.  Keep variables verbatim.
+        */
+       cf_expand_variables_set(false);
+
+       while ((c = getopt(argc, argv, "hi:o:r")) != -1) {
+               switch (c) {
+               case 'h':
+                       usage(EXIT_SUCCESS);
+
+               case 'i':
+                       input_file = optarg;
+                       break;
+
+               case 'o':
+                       output_file = optarg;
+                       break;
+
+               case 'r':
+                       strip_root = true;
+                       break;
+
+               default:
+                       usage(EXIT_FAILURE);
+               }
+       }
+
+       if (input_file) {
+               root_json = json_object_from_file(input_file);
+               if (!root_json) {
+                       fprintf(stderr, "Failed to parse %s: %s\n", input_file, json_util_get_last_err());
+                       return EXIT_FAILURE;
+               }
+       } else {
+               /* Slurp stdin and parse */
+               char                 buf[65536];
+               size_t               used = 0;
+               ssize_t              n;
+               struct json_tokener *tok = json_tokener_new();
+
+               while ((n = read(STDIN_FILENO, buf, sizeof(buf))) > 0) {
+                       root_json = json_tokener_parse_ex(tok, buf, n);
+                       if (json_tokener_get_error(tok) == json_tokener_continue) continue;
+                       if (json_tokener_get_error(tok) != json_tokener_success) {
+                               fprintf(stderr, "JSON parse error: %s\n",
+                                       json_tokener_error_desc(json_tokener_get_error(tok)));
+                               json_tokener_free(tok);
+                               return EXIT_FAILURE;
+                       }
+                       break;
+               }
+               (void)used;
+               json_tokener_free(tok);
+
+               if (!root_json) {
+                       fprintf(stderr, "No JSON read from stdin\n");
+                       return EXIT_FAILURE;
+               }
+       }
+
+       root_cs = build_root_section(autofree, root_json);
+       if (!root_cs) {
+               fprintf(stderr, "Failed to build conf tree\n");
+               json_object_put(root_json);
+               return EXIT_FAILURE;
+       }
+
+       if (output_file) {
+               out = fopen(output_file, "w");
+               if (!out) {
+                       fprintf(stderr, "Failed opening %s: %s\n", output_file, fr_syserror(errno));
+                       rcode = EXIT_FAILURE;
+                       goto finish;
+               }
+       } else {
+               out = stdout;
+       }
+
+       /*
+        *      Strip-root: write each child of the synthetic root at file
+        *      scope, no outer `{ ... }`.  cf_section_write_children handles
+        *      the same section/pair/comment dispatch (and blank-run
+        *      collapsing) that cf_section_write does internally; we just
+        *      skip the wrapper.
+        */
+       if (strip_root) {
+               if (cf_section_write_children(out, root_cs, 0) < 0) {
+                       fprintf(stderr, "cf_section_write_children failed\n");
+                       rcode = EXIT_FAILURE;
+               }
+       } else {
+               if (cf_section_write(out, root_cs, 0) < 0) {
+                       fprintf(stderr, "cf_section_write failed\n");
+                       rcode = EXIT_FAILURE;
+               }
+       }
+       if (out != stdout) fclose(out);
+
+finish:
+       talloc_free(root_cs);
+       json_object_put(root_json);
+       return rcode;
+}
diff --git a/src/bin/radjson2conf.mk b/src/bin/radjson2conf.mk
new file mode 100644 (file)
index 0000000..a3610e2
--- /dev/null
@@ -0,0 +1,24 @@
+#
+#  Only build radjson2conf if libfreeradius-json (the json-c wrapper)
+#  was configured.  Probe TARGETNAME via the lib's all.mk - it's empty
+#  when json-c wasn't found at autoconf time.  Reset TARGET so a stale
+#  value from the previous .mk in the pipeline doesn't sneak through.
+#
+TARGETNAME     :=
+-include $(top_builddir)/src/lib/json/all.mk
+TARGET         :=
+
+ifneq "$(TARGETNAME)" ""
+TARGETNAME     := radjson2conf
+TARGET         := $(TARGETNAME)$(E)
+endif
+
+SOURCES                := radjson2conf.c
+
+SRC_CFLAGS     += -I$(top_builddir)/src/lib/json/
+TGT_LDLIBS     += $(LIBS) $(LCRYPT)
+TGT_PREREQS    := $(LIBFREERADIUS_SERVER) libfreeradius-io$(L) libfreeradius-json$(L)
+
+ifneq ($(MAKECMDGOALS),scan)
+SRC_CFLAGS     += -DBUILT_WITH_CPPFLAGS=\"$(CPPFLAGS)\" -DBUILT_WITH_CFLAGS=\"$(CFLAGS)\" -DBUILT_WITH_LDFLAGS=\"$(LDFLAGS)\" -DBUILT_WITH_LIBS=\"$(LIBS)\"
+endif
diff --git a/src/bin/radmod2json.c b/src/bin/radmod2json.c
new file mode 100644 (file)
index 0000000..2c0937b
--- /dev/null
@@ -0,0 +1,676 @@
+/*
+ * radmod2json.c   Dump FreeRADIUS v4 module conf_parser_t and call_env_parser_t
+ *                 definitions as JSON.
+ *
+ *   This program is free software; you can redistribute it and/or modify
+ *   it under the terms of the GNU General Public License as published by
+ *   the Free Software Foundation; either version 2 of the License, or
+ *   (at your option) any later version.
+ *
+ *   This program is distributed in the hope that it will be useful,
+ *   but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *   GNU General Public License for more details.
+ *
+ *   You should have received a copy of the GNU General Public License
+ *   along with this program; if not, write to the Free Software
+ *   Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA
+ *
+ * Copyright (C) 2026 Arran Cudbard-Bell <a.cudbardb@freeradius.org>
+ */
+RCSID("$Id$")
+
+#include <freeradius-devel/server/base.h>
+#include <freeradius-devel/server/cf_parse.h>
+#include <freeradius-devel/server/dl_module.h>
+#include <freeradius-devel/server/module.h>
+#include <freeradius-devel/server/module_rlm.h>
+#include <freeradius-devel/server/section.h>
+#include <freeradius-devel/unlang/call_env.h>
+#include <freeradius-devel/util/atexit.h>
+#include <freeradius-devel/util/dl.h>
+#include <freeradius-devel/util/types.h>
+
+#include <json-c/json.h>
+
+#include <dirent.h>
+#include <dlfcn.h>
+#include <sys/wait.h>
+
+#ifdef HAVE_GETOPT_H
+#  include <getopt.h>
+#endif
+
+char const *radiusd_version = RADIUSD_VERSION_BUILD("radmod2json");
+
+static inline char const *quote_name(fr_token_t t)
+{
+       static char const *const quote_names[T_TOKEN_LAST] = {
+               [T_BARE_WORD]             = "T_BARE_WORD",
+               [T_DOUBLE_QUOTED_STRING]  = "T_DOUBLE_QUOTED_STRING",
+               [T_SINGLE_QUOTED_STRING]  = "T_SINGLE_QUOTED_STRING",
+               [T_BACK_QUOTED_STRING]    = "T_BACK_QUOTED_STRING",
+               [T_SOLIDUS_QUOTED_STRING] = "T_SOLIDUS_QUOTED_STRING",
+       };
+
+       char const *s;
+
+       if ((unsigned int)t >= T_TOKEN_LAST) return "T_BARE_WORD";
+       s = quote_names[t];
+       if (!s) return "T_BARE_WORD";
+       return s;
+}
+
+static inline char const *fr_type_full_name(fr_type_t t)
+{
+       static char const *const fr_type_enum_str[FR_TYPE_MAX + 1] = {
+               [FR_TYPE_NULL]             = "FR_TYPE_NULL",
+               [FR_TYPE_STRING]           = "FR_TYPE_STRING",
+               [FR_TYPE_OCTETS]           = "FR_TYPE_OCTETS",
+               [FR_TYPE_IPV4_ADDR]        = "FR_TYPE_IPV4_ADDR",
+               [FR_TYPE_IPV4_PREFIX]      = "FR_TYPE_IPV4_PREFIX",
+               [FR_TYPE_IPV6_ADDR]        = "FR_TYPE_IPV6_ADDR",
+               [FR_TYPE_IPV6_PREFIX]      = "FR_TYPE_IPV6_PREFIX",
+               [FR_TYPE_IFID]             = "FR_TYPE_IFID",
+               [FR_TYPE_COMBO_IP_ADDR]    = "FR_TYPE_COMBO_IP_ADDR",
+               [FR_TYPE_COMBO_IP_PREFIX]  = "FR_TYPE_COMBO_IP_PREFIX",
+               [FR_TYPE_ETHERNET]         = "FR_TYPE_ETHERNET",
+               [FR_TYPE_BOOL]             = "FR_TYPE_BOOL",
+               [FR_TYPE_UINT8]            = "FR_TYPE_UINT8",
+               [FR_TYPE_UINT16]           = "FR_TYPE_UINT16",
+               [FR_TYPE_UINT32]           = "FR_TYPE_UINT32",
+               [FR_TYPE_UINT64]           = "FR_TYPE_UINT64",
+               [FR_TYPE_INT8]             = "FR_TYPE_INT8",
+               [FR_TYPE_INT16]            = "FR_TYPE_INT16",
+               [FR_TYPE_INT32]            = "FR_TYPE_INT32",
+               [FR_TYPE_INT64]            = "FR_TYPE_INT64",
+               [FR_TYPE_FLOAT32]          = "FR_TYPE_FLOAT32",
+               [FR_TYPE_FLOAT64]          = "FR_TYPE_FLOAT64",
+               [FR_TYPE_DATE]             = "FR_TYPE_DATE",
+               [FR_TYPE_TIME_DELTA]       = "FR_TYPE_TIME_DELTA",
+               [FR_TYPE_SIZE]             = "FR_TYPE_SIZE",
+               [FR_TYPE_TLV]              = "FR_TYPE_TLV",
+               [FR_TYPE_STRUCT]           = "FR_TYPE_STRUCT",
+               [FR_TYPE_VSA]              = "FR_TYPE_VSA",
+               [FR_TYPE_VENDOR]           = "FR_TYPE_VENDOR",
+               [FR_TYPE_GROUP]            = "FR_TYPE_GROUP",
+               [FR_TYPE_UNION]            = "FR_TYPE_UNION",
+               [FR_TYPE_VALUE_BOX]        = "FR_TYPE_VALUE_BOX",
+               [FR_TYPE_ATTR]             = "FR_TYPE_ATTR",
+               [FR_TYPE_VOID]             = "FR_TYPE_VOID",
+               [FR_TYPE_VALUE_BOX_CURSOR] = "FR_TYPE_VALUE_BOX_CURSOR",
+               [FR_TYPE_PAIR_CURSOR]      = "FR_TYPE_PAIR_CURSOR",
+       };
+
+       char const *s;
+
+       if ((unsigned int)t >= NUM_ELEMENTS(fr_type_enum_str)) return "FR_TYPE_INVALID";
+       s = fr_type_enum_str[t];
+       if (!s) return "FR_TYPE_NULL";
+       return s;
+}
+
+/*
+ *     Resolve a function pointer back to its source symbol name via dladdr().
+ *     macOS C symbols get a leading '_' from the linker - strip that before
+ *     emitting.
+ */
+static struct json_object *func_symbol(void const *fn)
+{
+       Dl_info     info;
+       char const *name;
+
+       if (!fn) return NULL;
+       if (dladdr(fn, &info) == 0) return NULL;
+       if (!info.dli_sname) return NULL;
+
+       name = info.dli_sname;
+       if (name[0] == '_') name++;
+       return json_object_new_string(name);
+}
+
+static struct json_object *build_dflt(char const *value, fr_token_t quote)
+{
+       struct json_object *o;
+
+       if (!value) return NULL;
+       o = json_object_new_object();
+       json_object_object_add(o, "value", json_object_new_string(value));
+       json_object_object_add(o, "quote", json_object_new_string(quote_name(quote)));
+       return o;
+}
+
+/*
+ *     Walk every set bit of `flags`; for each one, look the single-bit
+ *     mask up in `table` via fr_table_str_by_value (the _Generic
+ *     dispatch routes a bit-pos table through fr_table_indexed_str_by_bit_field).
+ *     Push matches into a new JSON array in bit-order.
+ */
+static struct json_object *flags_to_json(fr_table_num_indexed_bit_pos_t const *table, size_t table_len, uint64_t flags)
+{
+       struct json_object *a = json_object_new_array();
+
+       for (size_t bit = 0; bit < 64; bit++) {
+               uint64_t    mask = UINT64_C(1) << bit;
+               char const *name;
+
+               if (!(flags & mask)) continue;
+               name = fr_table_str_by_value(table, mask, NULL);
+               if (!name) continue;
+               json_object_array_add(a, json_object_new_string(name));
+       }
+       return a;
+}
+
+static struct json_object *build_conf_parser_flags(conf_parser_flags_t flags)
+{
+       static fr_table_num_indexed_bit_pos_t const conf_flag_table[] = {
+               [2]  = { L("CONF_FLAG_SUBSECTION"), CONF_FLAG_SUBSECTION },
+               [11] = { L("CONF_FLAG_DEPRECATED"), CONF_FLAG_DEPRECATED },
+               [12] = { L("CONF_FLAG_REQUIRED"), CONF_FLAG_REQUIRED },
+               [13] = { L("CONF_FLAG_ATTRIBUTE"), CONF_FLAG_ATTRIBUTE },
+               [14] = { L("CONF_FLAG_SECRET"), CONF_FLAG_SECRET },
+               [15] = { L("CONF_FLAG_FILE_READABLE"), CONF_FLAG_FILE_READABLE },
+               [16] = { L("CONF_FLAG_FILE_WRITABLE"), CONF_FLAG_FILE_WRITABLE },
+               [17] = { L("CONF_FLAG_FILE_SOCKET"), CONF_FLAG_FILE_SOCKET },
+               [18] = { L("CONF_FLAG_FILE_EXISTS"), CONF_FLAG_FILE_EXISTS },
+               [19] = { L("CONF_FLAG_XLAT"), CONF_FLAG_XLAT },
+               [20] = { L("CONF_FLAG_TMPL"), CONF_FLAG_TMPL },
+               [21] = { L("CONF_FLAG_MULTI"), CONF_FLAG_MULTI },
+               [22] = { L("CONF_FLAG_NOT_EMPTY"), CONF_FLAG_NOT_EMPTY },
+               [23] = { L("CONF_FLAG_IS_SET"), CONF_FLAG_IS_SET },
+               [24] = { L("CONF_FLAG_OK_MISSING"), CONF_FLAG_OK_MISSING },
+               [25] = { L("CONF_FLAG_HIDDEN"), CONF_FLAG_HIDDEN },
+               [26] = { L("CONF_FLAG_REF"), CONF_FLAG_REF },
+               [27] = { L("CONF_FLAG_OPTIONAL"), CONF_FLAG_OPTIONAL },
+               [28] = { L("CONF_FLAG_ALWAYS_PARSE"), CONF_FLAG_ALWAYS_PARSE },
+               [29] = { L("CONF_FLAG_NO_OUTPUT"), CONF_FLAG_NO_OUTPUT },
+       };
+       static size_t conf_flag_table_len = NUM_ELEMENTS(conf_flag_table);
+
+       return flags_to_json(conf_flag_table, conf_flag_table_len, flags);
+}
+
+static struct json_object *build_conf_parser_rules(conf_parser_t const *rules);
+
+static struct json_object *build_conf_parser_rule(conf_parser_t const *r)
+{
+       struct json_object *o      = json_object_new_object();
+       bool                is_sub = (r->flags & CONF_FLAG_SUBSECTION) != 0;
+
+       json_object_object_add(o, "name1", r->name1 ? json_object_new_string(r->name1) : NULL);
+       json_object_object_add(o, "name2", r->name2 ? json_object_new_string(r->name2) : NULL);
+       json_object_object_add(o, "type", json_object_new_string(fr_type_full_name(r->type)));
+       json_object_object_add(o, "flags", build_conf_parser_flags(r->flags));
+       json_object_object_add(o, "func", func_symbol((void const *)r->func));
+       json_object_object_add(o, "on_read", func_symbol((void const *)r->on_read));
+
+       if (is_sub) {
+               json_object_object_add(o, "subcs",
+                                      r->subcs ? build_conf_parser_rules(r->subcs) : json_object_new_array());
+       } else {
+               json_object_object_add(o, "dflt", build_dflt(r->dflt, r->quote));
+       }
+
+       return o;
+}
+
+static struct json_object *build_conf_parser_rules(conf_parser_t const *rules)
+{
+       struct json_object *a = json_object_new_array();
+
+       if (rules) {
+               for (conf_parser_t const *r = rules; r->name1; r++) {
+                       json_object_array_add(a, build_conf_parser_rule(r));
+               }
+       }
+       return a;
+}
+
+/*
+ *     Symbolic names for each `CALL_ENV_FLAG_*` bit.  Same shape as
+ *     `conf_flag_table` - bit-pos-indexed `fr_table_num_indexed_bit_pos_t`,
+ *     walked via `flags_to_json`.
+ */
+static fr_table_num_indexed_bit_pos_t const call_env_flag_table[] = {
+       [1]  = { L("CALL_ENV_FLAG_REQUIRED"), CALL_ENV_FLAG_REQUIRED },
+       [2]  = { L("CALL_ENV_FLAG_CONCAT"), CALL_ENV_FLAG_CONCAT },
+       [3]  = { L("CALL_ENV_FLAG_SINGLE"), CALL_ENV_FLAG_SINGLE },
+       [4]  = { L("CALL_ENV_FLAG_MULTI"), CALL_ENV_FLAG_MULTI },
+       [5]  = { L("CALL_ENV_FLAG_NULLABLE"), CALL_ENV_FLAG_NULLABLE },
+       [6]  = { L("CALL_ENV_FLAG_FORCE_QUOTE"), CALL_ENV_FLAG_FORCE_QUOTE },
+       [7]  = { L("CALL_ENV_FLAG_PARSE_ONLY"), CALL_ENV_FLAG_PARSE_ONLY },
+       [8]  = { L("CALL_ENV_FLAG_ATTRIBUTE"), CALL_ENV_FLAG_ATTRIBUTE },
+       [9]  = { L("CALL_ENV_FLAG_SUBSECTION"), CALL_ENV_FLAG_SUBSECTION },
+       [10] = { L("CALL_ENV_FLAG_PARSE_MISSING"), CALL_ENV_FLAG_PARSE_MISSING },
+       [11] = { L("CALL_ENV_FLAG_SECRET"), CALL_ENV_FLAG_SECRET },
+       [12] = { L("CALL_ENV_FLAG_BARE_WORD_ATTRIBUTE"), CALL_ENV_FLAG_BARE_WORD_ATTRIBUTE },
+};
+static size_t call_env_flag_table_len = NUM_ELEMENTS(call_env_flag_table);
+
+static struct json_object *build_call_env_flags(call_env_flags_t flags)
+{
+       return flags_to_json(call_env_flag_table, call_env_flag_table_len, flags);
+}
+
+/*
+ *     Symbolic names for the call_env parse / result type enums,
+ *     indexed by enum value.  Both enums start at 1, so index 0 is a
+ *     NULL placeholder that the lookup falls back to the default for.
+ */
+static char const *const call_env_parse_type_names[] = {
+       [CALL_ENV_PARSE_TYPE_TMPL]      = "CALL_ENV_PARSE_TYPE_TMPL",
+       [CALL_ENV_PARSE_TYPE_VALUE_BOX] = "CALL_ENV_PARSE_TYPE_VALUE_BOX",
+       [CALL_ENV_PARSE_TYPE_VOID]      = "CALL_ENV_PARSE_TYPE_VOID",
+};
+
+static inline char const *call_env_parse_type_name(call_env_parse_type_t t)
+{
+       char const *s;
+
+       if ((unsigned int)t >= NUM_ELEMENTS(call_env_parse_type_names)) return "CALL_ENV_PARSE_TYPE_VOID";
+       s = call_env_parse_type_names[t];
+       if (!s) return "CALL_ENV_PARSE_TYPE_VOID";
+       return s;
+}
+
+static char const *const call_env_result_type_names[] = {
+       [CALL_ENV_RESULT_TYPE_VALUE_BOX]      = "CALL_ENV_RESULT_TYPE_VALUE_BOX",
+       [CALL_ENV_RESULT_TYPE_VALUE_BOX_LIST] = "CALL_ENV_RESULT_TYPE_VALUE_BOX_LIST",
+};
+
+static inline char const *call_env_result_type_name(call_env_result_type_t t)
+{
+       char const *s;
+
+       if ((unsigned int)t >= NUM_ELEMENTS(call_env_result_type_names)) return "CALL_ENV_RESULT_TYPE_VALUE_BOX";
+       s = call_env_result_type_names[t];
+       if (!s) return "CALL_ENV_RESULT_TYPE_VALUE_BOX";
+       return s;
+}
+
+static char const *section_ident(char const *name)
+{
+       if (name == CF_IDENT_ANY) return "*";
+       return name;
+}
+
+static struct json_object *json_section_ident(char const *name)
+{
+       char const *s = section_ident(name);
+       return s ? json_object_new_string(s) : NULL;
+}
+
+static struct json_object *build_call_env_rules(call_env_parser_t const *rules);
+
+static struct json_object *build_call_env_rule(call_env_parser_t const *r)
+{
+       struct json_object *o      = json_object_new_object();
+       bool                is_sub = (r->flags & CALL_ENV_FLAG_SUBSECTION) != 0;
+
+       json_object_object_add(o, "name", json_section_ident(r->name));
+       json_object_object_add(o, "flags", build_call_env_flags(r->flags));
+
+       if (is_sub) {
+               struct json_object *s = json_object_new_object();
+               json_object_object_add(s, "name2", json_section_ident(r->section.name2));
+               json_object_object_add(s, "func", func_symbol((void const *)r->section.func));
+               json_object_object_add(s, "subcs",
+                                      r->section.subcs ? build_call_env_rules(r->section.subcs) :
+                                                         json_object_new_array());
+               json_object_object_add(o, "section", s);
+       } else {
+               struct json_object *p      = json_object_new_object();
+               struct json_object *parsed = json_object_new_object();
+
+               /*
+                *      FR_TYPE_NULL / FR_TYPE_VOID both mean "no cast" - the
+                *      framework hands the parsed value through without
+                *      coercion.  Collapse to a JSON null so the converter
+                *      only has to check one sentinel.
+                */
+               json_object_object_add(p, "cast_type",
+                                      ((r->pair.cast_type == FR_TYPE_NULL) || (r->pair.cast_type == FR_TYPE_VOID)) ?
+                                              NULL :
+                                              json_object_new_string(fr_type_full_name(r->pair.cast_type)));
+               json_object_object_add(p, "type", json_object_new_string(call_env_result_type_name(r->pair.type)));
+               json_object_object_add(p, "dflt", build_dflt(r->pair.dflt, r->pair.dflt_quote));
+               json_object_object_add(p, "func", func_symbol((void const *)r->pair.func));
+
+               json_object_object_add(parsed, "type",
+                                      json_object_new_string(call_env_parse_type_name(r->pair.parsed.type)));
+               json_object_object_add(p, "parsed", parsed);
+
+               json_object_object_add(o, "pair", p);
+       }
+
+       return o;
+}
+
+static struct json_object *build_call_env_rules(call_env_parser_t const *rules)
+{
+       struct json_object *a = json_object_new_array();
+
+       if (rules) {
+               for (call_env_parser_t const *r = rules; r->name; r++) {
+                       json_object_array_add(a, build_call_env_rule(r));
+               }
+       }
+       return a;
+}
+
+static struct json_object *build_method_bindings(module_method_binding_t const *bindings)
+{
+       struct json_object *a = json_object_new_array();
+
+       if (bindings) {
+               for (module_method_binding_t const *b = bindings; b->section; b++) {
+                       struct json_object *entry   = json_object_new_object();
+                       struct json_object *section = json_object_new_object();
+
+                       json_object_object_add(section, "name1", json_section_ident(b->section->name1));
+                       json_object_object_add(section, "name2", json_section_ident(b->section->name2));
+                       json_object_object_add(entry, "section", section);
+                       json_object_object_add(entry, "method", func_symbol((void const *)b->method));
+                       json_object_object_add(entry, "env",
+                                              (b->method_env && b->method_env->env) ?
+                                                      build_call_env_rules(b->method_env->env) :
+                                                      json_object_new_array());
+
+                       json_object_array_add(a, entry);
+               }
+       }
+
+       return a;
+}
+
+/*
+ *     Build the JSON for one module using the server's normal dl loader.
+ *     `bare_name` is the module name without the "rlm_" prefix (so "files"
+ *     for rlm_files).  Returns the json object or NULL on error.
+ */
+static struct json_object *build_module(char const *bare_name)
+{
+       dl_module_t        *dl_module;
+       module_rlm_t       *m;
+       struct json_object *entry;
+
+       dl_module = dl_module_alloc(NULL, bare_name, DL_MODULE_TYPE_MODULE);
+       if (!dl_module) {
+               fr_perror("rlm_%s", bare_name);
+               return NULL;
+       }
+
+       m = (module_rlm_t *)dl_module->exported;
+       if (!m) return NULL;
+
+       entry = json_object_new_object();
+       json_object_object_add(entry, "module", json_object_new_string(m->common.name ? m->common.name : bare_name));
+       json_object_object_add(entry, "config", build_conf_parser_rules(m->common.config));
+       json_object_object_add(entry, "call_env", build_method_bindings(m->method_group.bindings));
+       return entry;
+}
+
+static int dump_module(struct json_object *modules, char const *bare_name)
+{
+       int   fds[2];
+       pid_t pid;
+       int   status;
+
+       if (pipe(fds) < 0) {
+               fprintf(stderr, "rlm_%s: pipe failed: %s\n", bare_name, fr_syserror(errno));
+               return -1;
+       }
+
+       pid = fork();
+       if (pid < 0) {
+               close(fds[0]);
+               close(fds[1]);
+               fprintf(stderr, "rlm_%s: fork failed: %s\n", bare_name, fr_syserror(errno));
+               return -1;
+       }
+
+       if (pid == 0) {
+               struct json_object *entry;
+
+               close(fds[0]);
+               entry = build_module(bare_name);
+               if (entry) {
+                       char const *s = json_object_to_json_string_ext(entry, JSON_C_TO_STRING_PLAIN |
+                                                                                     JSON_C_TO_STRING_NOSLASHESCAPE);
+                       (void)write(fds[1], s, strlen(s));
+                       json_object_put(entry);
+               }
+               close(fds[1]);
+               _exit(entry ? 0 : 1);
+       }
+
+       close(fds[1]);
+       {
+               char    buf[262144];
+               size_t  used = 0;
+               ssize_t n;
+               while ((n = read(fds[0], buf + used, sizeof(buf) - 1 - used)) > 0) {
+                       used += n;
+                       if (used >= sizeof(buf) - 1) break;
+               }
+               close(fds[0]);
+               buf[used] = '\0';
+
+               waitpid(pid, &status, 0);
+
+               if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
+                       fprintf(stderr, "rlm_%s: child %s (status=0x%x), skipping\n", bare_name,
+                               WIFEXITED(status) ? "exited non-zero" : "crashed", status);
+                       return -1;
+               }
+
+               if (used > 0) {
+                       struct json_object *entry = json_tokener_parse(buf);
+                       if (entry) {
+                               json_object_array_add(modules, entry);
+                               return 0;
+                       }
+                       fprintf(stderr, "rlm_%s: failed to parse child JSON\n", bare_name);
+                       return -1;
+               }
+       }
+
+       return -1;
+}
+
+/*
+ *     Discover top-level rlm_*.dylib (or .so) files in a directory, returning
+ *     the bare module names (no "rlm_" prefix, no extension).  The dl loader
+ *     will re-prefix and resolve the full path itself.
+ */
+static int discover_modules(char const *dir, char ***out_names, size_t *out_count)
+{
+       DIR           *d;
+       struct dirent *e;
+       char         **names = NULL;
+       size_t         n = 0, alloc = 0;
+       size_t         extlen = strlen(DL_EXTENSION);
+
+       d = opendir(dir);
+       if (!d) {
+               fprintf(stderr, "Failed opening module dir %s: %s\n", dir, fr_syserror(errno));
+               return -1;
+       }
+
+       while ((e = readdir(d)) != NULL) {
+               size_t nlen = strlen(e->d_name);
+               char  *name;
+
+               if (nlen <= extlen) continue;
+               if (strcmp(e->d_name + nlen - extlen, DL_EXTENSION) != 0) continue;
+               if (strncmp(e->d_name, "rlm_", 4) != 0) continue;
+
+               if (n == alloc) {
+                       alloc = alloc ? alloc * 2 : 16;
+                       names = realloc(names, alloc * sizeof(*names));
+               }
+
+               /* Strip "rlm_" prefix and DL_EXTENSION suffix */
+               name                    = strdup(e->d_name + 4);
+               name[nlen - 4 - extlen] = '\0';
+               names[n++]              = name;
+       }
+
+       closedir(d);
+
+       *out_names = names;
+       *out_count = n;
+       return 0;
+}
+
+static int compare_str(void const *a, void const *b)
+{
+       return strcmp(*(char *const *)a, *(char *const *)b);
+}
+
+static NEVER_RETURNS void usage(int rcode)
+{
+       FILE *fp = (rcode == 0) ? stdout : stderr;
+
+       fprintf(fp, "Usage: radmod2json [options]\n"
+                   "  -m <list>     Comma-separated list of module names without rlm_ prefix\n"
+                   "                (default: all rlm_*.dylib in -M dir).\n"
+                   "  -M <dir>      Module directory to load from.\n"
+                   "  -D <dict>     Dictionary directory (autoload uses this).\n"
+                   "  -o <file>     Write JSON to <file> (default stdout).\n"
+                   "  -x            Enable debug output (repeatable).\n"
+                   "  -h            This help.\n");
+       exit(rcode);
+}
+
+int main(int argc, char *argv[])
+{
+       int                 c;
+       int                 rcode       = EXIT_SUCCESS;
+       char const         *modules_arg = NULL;
+       char const         *output_file = NULL;
+       char const         *module_dir  = NULL;
+       char const         *dict_dir    = NULL;
+       char              **names       = NULL;
+       size_t              n_names     = 0;
+       char               *owned_list  = NULL;
+       fr_dict_t          *internal    = NULL;
+       struct json_object *root, *modules;
+
+       fr_debug_lvl    = 0;
+       default_log.dst = L_DST_STDERR;
+       default_log.fd  = STDERR_FILENO;
+
+       while ((c = getopt(argc, argv, "D:hm:M:o:x")) != -1) {
+               switch (c) {
+               case 'D':
+                       dict_dir = optarg;
+                       break;
+               case 'h':
+                       usage(EXIT_SUCCESS);
+               case 'm':
+                       modules_arg = optarg;
+                       break;
+               case 'M':
+                       module_dir = optarg;
+                       break;
+               case 'o':
+                       output_file = optarg;
+                       break;
+               case 'x':
+                       fr_debug_lvl++;
+                       break;
+               default:
+                       usage(EXIT_FAILURE);
+               }
+       }
+
+       if (fr_check_lib_magic(RADIUSD_MAGIC_NUMBER) < 0) {
+               fr_perror("radmod2json");
+               exit(EXIT_FAILURE);
+       }
+
+       if (!module_dir) {
+               fprintf(stderr, "Need -M <dir>\n");
+               exit(EXIT_FAILURE);
+       }
+
+       /* Init the dl loader against the requested module dir. */
+       modules_init(module_dir);
+
+       /*
+        *      dl_module_alloc triggers fr_dict_autoload on the loaded
+        *      module, which needs a global dict ctx to exist.  Some
+        *      modules also autoload internal-protocol attributes that
+        *      are in the dictionary tree, so load it up front.
+        */
+       if (!fr_dict_global_ctx_init(NULL, true, dict_dir)) {
+               fr_perror("radmod2json");
+               exit(EXIT_FAILURE);
+       }
+
+       if (fr_dict_internal_afrom_file(&internal, FR_DICTIONARY_INTERNAL_DIR, __FILE__) < 0) {
+               fr_perror("radmod2json");
+               exit(EXIT_FAILURE);
+       }
+
+       if (modules_arg) {
+               char  *list = strdup(modules_arg);
+               char  *p, *tok;
+               size_t alloc = 0;
+
+               owned_list = list;
+               for (p = list; (tok = strsep(&p, ",")) != NULL;) {
+                       char const *bare = tok;
+
+                       if (!*bare) continue;
+                       if (strncmp(bare, "rlm_", 4) == 0) bare += 4;
+                       if (n_names == alloc) {
+                               alloc = alloc ? alloc * 2 : 16;
+                               names = realloc(names, alloc * sizeof(*names));
+                       }
+                       names[n_names++] = (char *)bare;
+               }
+       } else {
+               if (discover_modules(module_dir, &names, &n_names) < 0) {
+                       exit(EXIT_FAILURE);
+               }
+               qsort(names, n_names, sizeof(names[0]), compare_str);
+       }
+
+       root    = json_object_new_object();
+       modules = json_object_new_array();
+       json_object_object_add(root, "modules", modules);
+
+       for (size_t i = 0; i < n_names; i++) {
+               if (dump_module(modules, names[i]) < 0) rcode = EXIT_FAILURE;
+       }
+
+       {
+               char const *json_str =
+                       json_object_to_json_string_ext(root, JSON_C_TO_STRING_PRETTY | JSON_C_TO_STRING_NOSLASHESCAPE);
+
+               if (output_file) {
+                       FILE *out = fopen(output_file, "w");
+                       if (!out) {
+                               fprintf(stderr, "Failed opening %s: %s\n", output_file, fr_syserror(errno));
+                               exit(EXIT_FAILURE);
+                       }
+                       fputs(json_str, out);
+                       fputc('\n', out);
+                       fclose(out);
+               } else {
+                       fputs(json_str, stdout);
+                       fputc('\n', stdout);
+               }
+       }
+
+       json_object_put(root);
+       if (owned_list) free(owned_list);
+       if (!modules_arg)
+               for (size_t i = 0; i < n_names; i++) free(names[i]);
+       free(names);
+
+       return rcode;
+}
diff --git a/src/bin/radmod2json.mk b/src/bin/radmod2json.mk
new file mode 100644 (file)
index 0000000..b5f01c6
--- /dev/null
@@ -0,0 +1,24 @@
+#
+#  Only build radmod2json if libfreeradius-json (the json-c wrapper)
+#  was configured.  Probe TARGETNAME via the lib's all.mk - it's empty
+#  when json-c wasn't found at autoconf time.  Reset TARGET so a stale
+#  value from the previous .mk in the pipeline doesn't sneak through.
+#
+TARGETNAME     :=
+-include $(top_builddir)/src/lib/json/all.mk
+TARGET         :=
+
+ifneq "$(TARGETNAME)" ""
+TARGETNAME     := radmod2json
+TARGET         := $(TARGETNAME)$(E)
+endif
+
+SOURCES                := radmod2json.c
+
+SRC_CFLAGS     += -I$(top_builddir)/src/lib/json/
+TGT_LDLIBS     += $(LIBS) $(LCRYPT) $(LIBDL)
+TGT_PREREQS    := $(LIBFREERADIUS_SERVER) libfreeradius-io$(L) libfreeradius-json$(L)
+
+ifneq ($(MAKECMDGOALS),scan)
+SRC_CFLAGS     += -DBUILT_WITH_CPPFLAGS=\"$(CPPFLAGS)\" -DBUILT_WITH_CFLAGS=\"$(CFLAGS)\" -DBUILT_WITH_LDFLAGS=\"$(LDFLAGS)\" -DBUILT_WITH_LIBS=\"$(LIBS)\"
+endif