From 0157fc0266418918f3c9222381b89fd2f8f4caa0 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 25 Sep 2026 10:20:13 +0200 Subject: [PATCH 1/5] build: serve netconf as an ssh subsystem Refactor netopeer2-server to act as an OpenSSH subsystem, similar to how sftp works. The SSH daemon authenticates the user and runs the new libnetconf2 netconf-subsystem helper, which bridges the session to a UNIX socket netopeer2-server listens on. The peer credentials of that socket tell the server who the user is, so NACM works as before, and netopeer2-server no longer needs an SSH implementation (libssh) of its own. The libnetconf2 patches add the helper and the API to create a UNIX endpoint without ietf-netconf-server; the netopeer2 patch teaches the server to use them. Buildroot gets an option for this mode, which the Infix defconfigs select. A build without it keeps libssh, NETCONF over TLS, call-home and netopeer2-cli. Signed-off-by: Joachim Wiberg --- buildroot | 2 +- configs/aarch64_defconfig | 3 +- configs/aarch64_minimal_defconfig | 2 +- configs/arm_defconfig | 2 +- configs/arm_minimal_defconfig | 2 +- configs/riscv64_defconfig | 3 +- configs/x86_64_defconfig | 3 +- configs/x86_64_minimal_defconfig | 2 +- ...eclare-the-UNIX-socket-path-API-with.patch | 40 +++ ...-disconnecting-is-not-an-error-on-UN.patch | 74 +++++ ...NIX-endpoint-without-ietf-netconf-se.patch | 153 +++++++++ ...-add-a-NETCONF-subsystem-for-OpenSSH.patch | 298 ++++++++++++++++++ ...run-behind-an-SSH-daemon-with-U-PATH.patch | 228 ++++++++++++++ 13 files changed, 801 insertions(+), 11 deletions(-) create mode 100644 patches/libnetconf2/4.1.2/0003-session_server-declare-the-UNIX-socket-path-API-with.patch create mode 100644 patches/libnetconf2/4.1.2/0004-session-a-client-disconnecting-is-not-an-error-on-UN.patch create mode 100644 patches/libnetconf2/4.1.2/0005-server-allow-a-UNIX-endpoint-without-ietf-netconf-se.patch create mode 100644 patches/libnetconf2/4.1.2/0006-tools-add-a-NETCONF-subsystem-for-OpenSSH.patch create mode 100644 patches/netopeer2/2.7.0/0005-main-run-behind-an-SSH-daemon-with-U-PATH.patch diff --git a/buildroot b/buildroot index 04ebd60f8..2f8fda57a 160000 --- a/buildroot +++ b/buildroot @@ -1 +1 @@ -Subproject commit 04ebd60f8d68497970b72db06f31b7d18037cead +Subproject commit 2f8fda57a6a05043cc0bc3b749514d0e46dc63e5 diff --git a/configs/aarch64_defconfig b/configs/aarch64_defconfig index dce29906d..6d0d399e0 100644 --- a/configs/aarch64_defconfig +++ b/configs/aarch64_defconfig @@ -49,13 +49,12 @@ BR2_PACKAGE_UBOOT_TOOLS_FIT_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_SIGNATURE_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_CHECK_SIGN=y BR2_PACKAGE_UBOOT_TOOLS_MKENVIMAGE=y -BR2_PACKAGE_LIBSSH_OPENSSL=y +BR2_PACKAGE_LIBNETCONF2_SSH_SUBSYSTEM=y BR2_PACKAGE_LIBSSH2=y BR2_PACKAGE_LIBSSH2_OPENSSL=y BR2_PACKAGE_LIBOPENSSL_BIN=y BR2_PACKAGE_LIBINPUT=y BR2_PACKAGE_LIBCURL_CURL=y -BR2_PACKAGE_NETOPEER2_CLI=y BR2_PACKAGE_NSS_MDNS=y BR2_PACKAGE_SYSREPO_GROUP="sysrepo" BR2_PACKAGE_LINUX_PAM=y diff --git a/configs/aarch64_minimal_defconfig b/configs/aarch64_minimal_defconfig index 81e906378..7bdd3f652 100644 --- a/configs/aarch64_minimal_defconfig +++ b/configs/aarch64_minimal_defconfig @@ -49,11 +49,11 @@ BR2_PACKAGE_UBOOT_TOOLS_FIT_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_SIGNATURE_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_CHECK_SIGN=y BR2_PACKAGE_UBOOT_TOOLS_MKENVIMAGE=y +BR2_PACKAGE_LIBNETCONF2_SSH_SUBSYSTEM=y BR2_PACKAGE_LIBSSH2=y BR2_PACKAGE_LIBOPENSSL_BIN=y BR2_PACKAGE_LIBINPUT=y BR2_PACKAGE_LIBCURL_CURL=y -BR2_PACKAGE_NETOPEER2_CLI=y BR2_PACKAGE_NSS_MDNS=y BR2_PACKAGE_SYSREPO_GROUP="sysrepo" BR2_PACKAGE_LINUX_PAM=y diff --git a/configs/arm_defconfig b/configs/arm_defconfig index 8000c882f..a48ad1138 100644 --- a/configs/arm_defconfig +++ b/configs/arm_defconfig @@ -51,11 +51,11 @@ BR2_PACKAGE_UBOOT_TOOLS_FIT_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_SIGNATURE_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_CHECK_SIGN=y BR2_PACKAGE_UBOOT_TOOLS_MKENVIMAGE=y +BR2_PACKAGE_LIBNETCONF2_SSH_SUBSYSTEM=y BR2_PACKAGE_LIBSSH2=y BR2_PACKAGE_LIBOPENSSL_BIN=y BR2_PACKAGE_LIBINPUT=y BR2_PACKAGE_LIBCURL_CURL=y -BR2_PACKAGE_NETOPEER2_CLI=y BR2_PACKAGE_NSS_MDNS=y BR2_PACKAGE_SYSREPO_GROUP="sysrepo" BR2_PACKAGE_LINUX_PAM=y diff --git a/configs/arm_minimal_defconfig b/configs/arm_minimal_defconfig index 003ce23e6..91cbef4b4 100644 --- a/configs/arm_minimal_defconfig +++ b/configs/arm_minimal_defconfig @@ -51,11 +51,11 @@ BR2_PACKAGE_UBOOT_TOOLS_FIT_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_SIGNATURE_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_CHECK_SIGN=y BR2_PACKAGE_UBOOT_TOOLS_MKENVIMAGE=y +BR2_PACKAGE_LIBNETCONF2_SSH_SUBSYSTEM=y BR2_PACKAGE_LIBSSH2=y BR2_PACKAGE_LIBOPENSSL_BIN=y BR2_PACKAGE_LIBINPUT=y BR2_PACKAGE_LIBCURL_CURL=y -BR2_PACKAGE_NETOPEER2_CLI=y BR2_PACKAGE_NSS_MDNS=y BR2_PACKAGE_SYSREPO_GROUP="sysrepo" BR2_PACKAGE_LINUX_PAM=y diff --git a/configs/riscv64_defconfig b/configs/riscv64_defconfig index 51d49ee81..d2e3478bb 100644 --- a/configs/riscv64_defconfig +++ b/configs/riscv64_defconfig @@ -59,13 +59,12 @@ BR2_PACKAGE_UBOOT_TOOLS_FIT_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_SIGNATURE_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_CHECK_SIGN=y BR2_PACKAGE_UBOOT_TOOLS_MKENVIMAGE=y -BR2_PACKAGE_LIBSSH_OPENSSL=y +BR2_PACKAGE_LIBNETCONF2_SSH_SUBSYSTEM=y BR2_PACKAGE_LIBSSH2=y BR2_PACKAGE_LIBSSH2_OPENSSL=y BR2_PACKAGE_LIBOPENSSL_BIN=y BR2_PACKAGE_LIBINPUT=y BR2_PACKAGE_LIBCURL_CURL=y -BR2_PACKAGE_NETOPEER2_CLI=y BR2_PACKAGE_NSS_MDNS=y BR2_PACKAGE_SYSREPO_GROUP="sysrepo" BR2_PACKAGE_LINUX_PAM=y diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig index ffdf0ee3e..5b58a3a36 100644 --- a/configs/x86_64_defconfig +++ b/configs/x86_64_defconfig @@ -48,13 +48,12 @@ BR2_PACKAGE_UBOOT_TOOLS_FIT_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_SIGNATURE_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_CHECK_SIGN=y BR2_PACKAGE_UBOOT_TOOLS_MKENVIMAGE=y -BR2_PACKAGE_LIBSSH_OPENSSL=y +BR2_PACKAGE_LIBNETCONF2_SSH_SUBSYSTEM=y BR2_PACKAGE_LIBSSH2=y BR2_PACKAGE_LIBSSH2_OPENSSL=y BR2_PACKAGE_LIBOPENSSL_BIN=y BR2_PACKAGE_LIBINPUT=y BR2_PACKAGE_LIBCURL_CURL=y -BR2_PACKAGE_NETOPEER2_CLI=y BR2_PACKAGE_NSS_MDNS=y BR2_PACKAGE_SYSREPO_GROUP="sysrepo" BR2_PACKAGE_LINUX_PAM=y diff --git a/configs/x86_64_minimal_defconfig b/configs/x86_64_minimal_defconfig index 458a47bda..e39d0eb15 100644 --- a/configs/x86_64_minimal_defconfig +++ b/configs/x86_64_minimal_defconfig @@ -48,11 +48,11 @@ BR2_PACKAGE_UBOOT_TOOLS_FIT_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_SIGNATURE_SUPPORT=y BR2_PACKAGE_UBOOT_TOOLS_FIT_CHECK_SIGN=y BR2_PACKAGE_UBOOT_TOOLS_MKENVIMAGE=y +BR2_PACKAGE_LIBNETCONF2_SSH_SUBSYSTEM=y BR2_PACKAGE_LIBSSH2=y BR2_PACKAGE_LIBOPENSSL_BIN=y BR2_PACKAGE_LIBINPUT=y BR2_PACKAGE_LIBCURL_CURL=y -BR2_PACKAGE_NETOPEER2_CLI=y BR2_PACKAGE_NSS_MDNS=y BR2_PACKAGE_SYSREPO_GROUP="sysrepo" BR2_PACKAGE_LINUX_PAM=y diff --git a/patches/libnetconf2/4.1.2/0003-session_server-declare-the-UNIX-socket-path-API-with.patch b/patches/libnetconf2/4.1.2/0003-session_server-declare-the-UNIX-socket-path-API-with.patch new file mode 100644 index 000000000..3e6ce7ae0 --- /dev/null +++ b/patches/libnetconf2/4.1.2/0003-session_server-declare-the-UNIX-socket-path-API-with.patch @@ -0,0 +1,40 @@ +From 2f558a50ec713f78175c43c4af05452f88af413e Mon Sep 17 00:00:00 2001 +From: Joachim Wiberg +Date: Fri, 25 Sep 2026 08:24:40 +0200 +Subject: [PATCH 3/6] session_server: declare the UNIX socket path API without + SSH/TLS +Organization: Wires + +With ENABLE_SSH_TLS=OFF these functions are built but their prototypes +are not, since they sit inside the NC_ENABLED_SSH_TLS block, so a +server using a UNIX socket endpoint fails to compile. + +--- + src/session_server.h | 4 ++++ + 1 file changed, 4 insertions(+) + +diff --git a/src/session_server.h b/src/session_server.h +index 18f925f..1fddf98 100644 +--- a/src/session_server.h ++++ b/src/session_server.h +@@ -447,6 +447,8 @@ NC_MSG_TYPE nc_session_accept_ssh_channel(struct nc_session *orig_session, struc + */ + NC_MSG_TYPE nc_ps_accept_ssh_channel(struct nc_pollsession *ps, struct nc_session **session); + ++#endif /* NC_ENABLED_SSH_TLS */ ++ + /** + * @brief Set the UNIX socket path for a given endpoint name. + * +@@ -490,6 +492,8 @@ int nc_server_get_unix_socket_dir(char **dir); + + /** @} Server Session */ + ++#ifdef NC_ENABLED_SSH_TLS ++ + /** + * @defgroup server_ssh Server SSH + * @ingroup server +-- +2.43.0 + diff --git a/patches/libnetconf2/4.1.2/0004-session-a-client-disconnecting-is-not-an-error-on-UN.patch b/patches/libnetconf2/4.1.2/0004-session-a-client-disconnecting-is-not-an-error-on-UN.patch new file mode 100644 index 000000000..633787aa3 --- /dev/null +++ b/patches/libnetconf2/4.1.2/0004-session-a-client-disconnecting-is-not-an-error-on-UN.patch @@ -0,0 +1,74 @@ +From 81c3b34c9b6059988bbf2688a7db6704fabc59c4 Mon Sep 17 00:00:00 2001 +From: Joachim Wiberg +Date: Fri, 25 Sep 2026 08:24:50 +0200 +Subject: [PATCH 4/6] session: a client disconnecting is not an error on UNIX + and FD +Organization: Wires + +A client closing its end of the socket is how these sessions normally +end, yet every one of them was logged as an error. + +A plain NC_PSPOLL_SESSION_TERM result also fell through the switch in +nc_ps_poll_session(), so the session was left busy and the reason for +its termination was never logged. + +--- + src/io.c | 4 ++-- + src/session_server.c | 10 ++++++++-- + 2 files changed, 10 insertions(+), 4 deletions(-) + +diff --git a/src/io.c b/src/io.c +index 9d55a0f..80fada3 100644 +--- a/src/io.c ++++ b/src/io.c +@@ -104,7 +104,7 @@ nc_read(struct nc_session *session, char *buf, uint32_t count, uint32_t inact_ti + return -1; + } + } else if (r == 0) { +- ERR(session, "Communication file descriptor (%d) unexpectedly closed.", fd); ++ VRB(session, "Communication file descriptor (%d) closed by the other side.", fd); + session->status = NC_STATUS_INVALID; + session->term_reason = NC_SESSION_TERM_DROPPED; + return -1; +@@ -447,7 +447,7 @@ nc_read_poll(struct nc_session *session, int io_timeout) + /* Some poll() implementations may return POLLHUP|POLLIN when the other + * side has closed but there is data left to read in the buffer. */ + if ((fds.revents & POLLHUP) && !(fds.revents & POLLIN)) { +- ERR(session, "Communication channel unexpectedly closed."); ++ VRB(session, "Communication channel closed by the other side."); + session->status = NC_STATUS_INVALID; + session->term_reason = NC_SESSION_TERM_DROPPED; + return -1; +diff --git a/src/session_server.c b/src/session_server.c +index 710d66c..e010955 100644 +--- a/src/session_server.c ++++ b/src/session_server.c +@@ -2173,10 +2173,11 @@ nc_ps_poll_session_io(struct nc_session *session, int io_timeout, time_t now_mon + ret = NC_PSPOLL_ERROR; + } else if (r > 0) { + if (pfd.revents & (POLLHUP | POLLNVAL)) { +- sprintf(msg, "Communication socket unexpectedly closed"); ++ /* the peer closing the socket is how a session normally ends */ ++ sprintf(msg, "Communication socket closed by the other side"); + session->status = NC_STATUS_INVALID; + session->term_reason = NC_SESSION_TERM_DROPPED; +- ret = NC_PSPOLL_SESSION_TERM | NC_PSPOLL_SESSION_ERROR; ++ ret = NC_PSPOLL_SESSION_TERM; + } else if (pfd.revents & POLLERR) { + sprintf(msg, "Communication socket error"); + session->status = NC_STATUS_INVALID; +@@ -2236,6 +2237,11 @@ nc_ps_poll_sess(struct nc_ps_session *ps_session, time_t now_mono) + ERR(ps_session->session, "%s.", msg); + ps_session->state = NC_PS_STATE_INVALID; + break; ++ case NC_PSPOLL_SESSION_TERM: ++ /* the peer went away, expected end of a session */ ++ VRB(ps_session->session, "%s.", msg); ++ ps_session->state = NC_PS_STATE_INVALID; ++ break; + case NC_PSPOLL_ERROR: + ERR(ps_session->session, "%s.", msg); + ps_session->state = NC_PS_STATE_NONE; +-- +2.43.0 + diff --git a/patches/libnetconf2/4.1.2/0005-server-allow-a-UNIX-endpoint-without-ietf-netconf-se.patch b/patches/libnetconf2/4.1.2/0005-server-allow-a-UNIX-endpoint-without-ietf-netconf-se.patch new file mode 100644 index 000000000..86d215654 --- /dev/null +++ b/patches/libnetconf2/4.1.2/0005-server-allow-a-UNIX-endpoint-without-ietf-netconf-se.patch @@ -0,0 +1,153 @@ +From 542c9765de84e53246f604d807fcde8ee2afcd0c Mon Sep 17 00:00:00 2001 +From: Joachim Wiberg +Date: Fri, 25 Sep 2026 08:25:10 +0200 +Subject: [PATCH 5/6] server: allow a UNIX endpoint without ietf-netconf-server +Organization: Wires + +A server that leaves SSH to the system SSH daemon needs just one UNIX +socket endpoint. Endpoints can only be created from ietf-netconf-server +data though, so such a server still had to load that module, and the +SSH and TLS modules it depends on, only to describe a single socket. + +--- + src/server_config.c | 10 +++++++ + src/session_server.c | 67 ++++++++++++++++++++++++++++++++++++++++++++ + src/session_server.h | 16 +++++++++++ + 3 files changed, 93 insertions(+) + +diff --git a/src/server_config.c b/src/server_config.c +index 54c470c..b0dd99c 100644 +--- a/src/server_config.c ++++ b/src/server_config.c +@@ -4068,6 +4068,11 @@ nc_server_config_netconf_server(const struct lyd_node *tree, int is_diff, struct + struct lyd_node *subtree; + enum nc_operation initial_op; + ++ /* not loaded by a server that only listens on a UNIX socket */ ++ if (!ly_ctx_get_module_implemented(LYD_CTX(tree), "ietf-netconf-server")) { ++ return 0; ++ } ++ + prev_lo = ly_log_options(0); + + /* try to find the netconf-server subtree */ +@@ -5106,6 +5111,11 @@ nc_server_config_libnetconf2_netconf_server(const struct lyd_node *tree, int is_ + struct lyd_node *subtree; + enum nc_operation initial_op; + ++ /* not loaded by a server that only listens on a UNIX socket */ ++ if (!ly_ctx_get_module_implemented(LYD_CTX(tree), "libnetconf2-netconf-server")) { ++ return 0; ++ } ++ + prev_lo = ly_log_options(0); + + /* try to find the ln2-netconf-server subtree */ +diff --git a/src/session_server.c b/src/session_server.c +index e010955..8a7ad4a 100644 +--- a/src/session_server.c ++++ b/src/session_server.c +@@ -4492,6 +4492,73 @@ cleanup: + return rc; + } + ++API int ++nc_server_add_unix_endpt(const char *endpoint_name, const char *socket_path, mode_t mode) ++{ ++ int rc = 0, r; ++ LY_ARRAY_COUNT_TYPE i; ++ struct nc_endpt *endpt = NULL; ++ struct nc_bind *bind = NULL; ++ ++ NC_CHECK_ARG_RET(NULL, endpoint_name, socket_path, 1); ++ ++ /* a hidden-path endpoint, its socket path is kept outside the configuration */ ++ if (nc_server_set_unix_socket_path(endpoint_name, socket_path)) { ++ return 1; ++ } ++ ++ /* CONFIG WRITE LOCK */ ++ pthread_rwlock_wrlock(&server_opts.config_lock); ++ ++ LY_ARRAY_FOR(server_opts.config.endpts, i) { ++ if (!strcmp(server_opts.config.endpts[i].name, endpoint_name)) { ++ ERR(NULL, "Endpoint \"%s\" already exists.", endpoint_name); ++ rc = 1; ++ goto cleanup; ++ } ++ } ++ ++ LY_ARRAY_NEW_GOTO(NULL, server_opts.config.endpts, endpt, rc, cleanup); ++ if ((r = pthread_mutex_init(&endpt->bind_lock, NULL))) { ++ ERR(NULL, "Mutex init failed (%s).", strerror(r)); ++ LY_ARRAY_DECREMENT_FREE(server_opts.config.endpts); ++ rc = 1; ++ goto cleanup; ++ } ++ ++ endpt->name = strdup(endpoint_name); ++ NC_CHECK_ERRMEM_GOTO(!endpt->name, rc = 1, error); ++ ++ endpt->ti = NC_TI_UNIX; ++ endpt->opts.unix = calloc(1, sizeof *endpt->opts.unix); ++ NC_CHECK_ERRMEM_GOTO(!endpt->opts.unix, rc = 1, error); ++ endpt->opts.unix->path_type = NC_UNIX_SOCKET_PATH_HIDDEN; ++ endpt->opts.unix->mode = mode; ++ endpt->opts.unix->uid = (uid_t)-1; ++ endpt->opts.unix->gid = (gid_t)-1; ++ ++ LY_ARRAY_NEW_GOTO(NULL, endpt->binds, bind, rc, error); ++ bind->sock = -1; ++ ++ if (nc_server_bind_and_listen(endpt, bind)) { ++ rc = 1; ++ goto error; ++ } ++ goto cleanup; ++ ++error: ++ free(endpt->name); ++ free(endpt->opts.unix); ++ LY_ARRAY_FREE(endpt->binds); ++ pthread_mutex_destroy(&endpt->bind_lock); ++ LY_ARRAY_DECREMENT_FREE(server_opts.config.endpts); ++ ++cleanup: ++ /* CONFIG WRITE UNLOCK */ ++ pthread_rwlock_unlock(&server_opts.config_lock); ++ return rc; ++} ++ + API int + nc_server_get_unix_socket_path(const char *endpoint_name, char **socket_path) + { +diff --git a/src/session_server.h b/src/session_server.h +index 1fddf98..719de2b 100644 +--- a/src/session_server.h ++++ b/src/session_server.h +@@ -461,6 +461,22 @@ NC_MSG_TYPE nc_ps_accept_ssh_channel(struct nc_pollsession *ps, struct nc_sessio + */ + int nc_server_set_unix_socket_path(const char *endpoint_name, const char *socket_path); + ++/** ++ * @brief Create a UNIX socket listen endpoint without any YANG configuration. ++ * ++ * For servers that run behind an SSH daemon and do not implement ++ * ietf-netconf-server. The endpoint starts listening immediately and ++ * behaves like a "hidden-path" UNIX endpoint: clients are authenticated by ++ * their socket peer credentials and there are no user mappings, so the ++ * NETCONF username must be the system username. ++ * ++ * @param[in] endpoint_name Name of the new endpoint, must be unique. ++ * @param[in] socket_path Absolute UNIX socket path to listen on. ++ * @param[in] mode Socket file permissions, (mode_t)-1 to leave them to umask. ++ * @return 0 on success, 1 on error. ++ */ ++int nc_server_add_unix_endpt(const char *endpoint_name, const char *socket_path, mode_t mode); ++ + /** + * @brief Get the UNIX socket path for a given endpoint name. + * +-- +2.43.0 + diff --git a/patches/libnetconf2/4.1.2/0006-tools-add-a-NETCONF-subsystem-for-OpenSSH.patch b/patches/libnetconf2/4.1.2/0006-tools-add-a-NETCONF-subsystem-for-OpenSSH.patch new file mode 100644 index 000000000..c3c119c09 --- /dev/null +++ b/patches/libnetconf2/4.1.2/0006-tools-add-a-NETCONF-subsystem-for-OpenSSH.patch @@ -0,0 +1,298 @@ +From b35331368cd28b041b2609df67fe387e7cac14ac Mon Sep 17 00:00:00 2001 +From: Joachim Wiberg +Date: Fri, 25 Sep 2026 08:25:11 +0200 +Subject: [PATCH 6/6] tools: add a NETCONF subsystem for OpenSSH +Organization: Wires + +Lets a server built without SSH and TLS support still offer NETCONF +over SSH, with the system SSH daemon handling authentication, keys and +ciphers the same way it does for every other SSH user on the system. + +The helper is off by default since it is only useful with a server +built with ENABLE_SSH_TLS=OFF. + +--- + CMakeLists.txt | 7 ++ + README.md | 44 +++++++++++ + tools/CMakeLists.txt | 12 +++ + tools/netconf-subsystem.c | 158 ++++++++++++++++++++++++++++++++++++++ + 4 files changed, 221 insertions(+) + create mode 100644 tools/CMakeLists.txt + create mode 100644 tools/netconf-subsystem.c + +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 3f6d845..d6315d4 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -91,10 +91,12 @@ endif() + option(ENABLE_EXAMPLES "Build examples" ON) + option(ENABLE_COVERAGE "Build code coverage report from tests" OFF) + option(ENABLE_SSH_TLS "Enable NETCONF over SSH and TLS support (via libssh and OpenSSL)" ON) ++option(ENABLE_SUBSYSTEM "Build netconf-subsystem, a NETCONF subsystem for OpenSSH" OFF) + option(ENABLE_DNSSEC "Enable support for SSHFP retrieval using DNSSEC for SSH (requires OpenSSL and libval)" OFF) + option(ENABLE_PAM "Detect and use PAM" ON) + option(ENABLE_COMMON_TARGETS "Define common custom target names such as 'doc' or 'uninstall', may cause conflicts when using add_subdirectory() to build this project" ON) + option(BUILD_SHARED_LIBS "By default, shared libs are enabled. Turn off for a static build." ON) ++set(NC_SUBSYSTEM_SOCKET "/run/netconf.sock" CACHE STRING "Default UNIX socket path netconf-subsystem connects to") + set(READ_INACTIVE_TIMEOUT 20 CACHE STRING "Maximum number of seconds waiting for new data once some data have arrived") + set(READ_ACTIVE_TIMEOUT 300 CACHE STRING "Maximum number of seconds for receiving a full message") + set(MAX_PSPOLL_THREAD_COUNT 6 CACHE STRING "Maximum number of threads that could simultaneously access a ps_poll structure") +@@ -366,6 +368,11 @@ if(ENABLE_EXAMPLES) + endif() + endif() + ++# sshd subsystem helper ++if(ENABLE_SUBSYSTEM) ++ add_subdirectory(tools) ++endif() ++ + # tests + if(ENABLE_TESTS) + enable_testing() +diff --git a/README.md b/README.md +index ab05cc1..2fba254 100644 +--- a/README.md ++++ b/README.md +@@ -17,6 +17,8 @@ NETCONF 1.0 ([RFC 4741](https://tools.ietf.org/html/rfc4741)) as well as NETCONF + + * NETCONF over SSH ([RFC 4742](https://tools.ietf.org/html/rfc4742), [RFC 6242](https://tools.ietf.org/html/rfc6242)), + using [libssh](https://www.libssh.org/). ++ * Optionally as a *subsystem* of [OpenSSH](https://www.openssh.com/), without libssh, see ++ [NETCONF as an OpenSSH subsystem](#netconf-as-an-openssh-subsystem). + * NETCONF over TLS ([RFC 7589](https://tools.ietf.org/html/rfc7589)), using [OpenSSL](https://www.openssl.org/). + * DNSSEC SSH Key Fingerprints ([RFC 4255](https://tools.ietf.org/html/rfc4255)) + * NETCONF over pre-established transport sessions (using this mechanism the communication can be tunneled through +@@ -127,6 +129,48 @@ specifying no option since it specifies the default settings. + $ cmake -DENABLE_SSH_TLS=ON .. + ``` + ++### NETCONF as an OpenSSH subsystem ++ ++A **libnetconf2** server can also be run as a *subsystem* of OpenSSH, in the ++same way as `sftp-server`. The SSH daemon authenticates the user and starts ++`netconf-subsystem`, a small helper that bridges the session to a UNIX socket ++the server listens on. The username comes from the socket peer credentials, ++so NACM and session monitoring work as usual, while users, keys and ciphers ++are managed in the OpenSSH daemon configuration. ++ ++This mode has its limitations. The library must be built without its own ++transports, `ENABLE_SSH_TLS=OFF`, which means no NETCONF over TLS (RFC 7589), ++no Call Home (RFC 8071), and no `ietf-netconf-server.yang` configuration: ++listen addresses, ports, and host keys are all OpenSSH settings. In return ++the library depends on nothing but **libyang**. ++ ++The helper is not built by default, enable it and, optionally, change the ++socket it connects to: ++ ++``` ++$ cmake -DENABLE_SSH_TLS=OFF -DENABLE_SUBSYSTEM=ON -DNC_SUBSYSTEM_SOCKET=/run/netconf.sock .. ++``` ++ ++On the OpenSSH side, declare the subsystem in `sshd_config`: ++ ++``` ++Subsystem netconf /usr/libexec/libnetconf2/netconf-subsystem ++``` ++ ++Clients then connect with `ssh -s host netconf`. To also serve the standard ++NETCONF port, 830 (RFC 4742), let OpenSSH listen there too and dedicate the ++port to NETCONF, so that no login shell is reachable through it: ++ ++``` ++Port 22 ++Port 830 ++Match LocalPort 830 ++ ForceCommand /usr/libexec/libnetconf2/netconf-subsystem ++ PermitTTY no ++ AllowTcpForwarding no ++ X11Forwarding no ++``` ++ + ### DNSSEC SSHFP Retrieval + + In SSH connections, if the remote NETCONF server supports it and it is +diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt +new file mode 100644 +index 0000000..d8c010e +--- /dev/null ++++ b/tools/CMakeLists.txt +@@ -0,0 +1,12 @@ ++if(NOT LIBNETCONF2_VERSION) ++ message(FATAL_ERROR "Please use the root CMakeLists file instead.") ++endif() ++ ++# use the generated public headers ++include_directories(BEFORE "${PROJECT_BINARY_DIR}/include") ++ ++add_executable(netconf-subsystem netconf-subsystem.c) ++target_link_libraries(netconf-subsystem netconf2) ++target_compile_definitions(netconf-subsystem PRIVATE NC_SUBSYSTEM_SOCKET="${NC_SUBSYSTEM_SOCKET}") ++ ++install(TARGETS netconf-subsystem DESTINATION ${CMAKE_INSTALL_LIBEXECDIR}/libnetconf2) +diff --git a/tools/netconf-subsystem.c b/tools/netconf-subsystem.c +new file mode 100644 +index 0000000..28e5516 +--- /dev/null ++++ b/tools/netconf-subsystem.c +@@ -0,0 +1,158 @@ ++/** ++ * @file netconf-subsystem.c ++ * @author Joachim Wiberg ++ * @brief NETCONF subsystem for sshd, bridges stdio to a UNIX socket endpoint ++ * ++ * Lets an SSH daemon own the SSH transport for a libnetconf2 server that ++ * listens on a UNIX socket, see nc_server_add_unix_endpt(): ++ * ++ * Subsystem netconf /usr/libexec/libnetconf2/netconf-subsystem ++ * ++ * sshd has already authenticated the user and runs us as that user. The ++ * server learns who we are from the socket peer credentials, so this is a ++ * plain byte pump that neither frames nor parses NETCONF. ++ * ++ * @copyright ++ * Copyright (c) 2026 Joachim Wiberg ++ * ++ * This source code is licensed under BSD 3-Clause License (the "License"). ++ * You may not use this file except in compliance with the License. ++ * You may obtain a copy of the License at ++ * ++ * https://opensource.org/licenses/BSD-3-Clause ++ */ ++#define _GNU_SOURCE ++ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++#include "nc_client.h" ++ ++#ifndef NC_SUBSYSTEM_SOCKET ++# define NC_SUBSYSTEM_SOCKET "/run/netconf.sock" ++#endif ++ ++static int ++write_all(int fd, const char *buf, size_t len) ++{ ++ ssize_t n; ++ ++ while (len) { ++ n = write(fd, buf, len); ++ if (n < 0) { ++ if (errno == EINTR) { ++ continue; ++ } ++ return -1; ++ } ++ buf += n; ++ len -= n; ++ } ++ ++ return 0; ++} ++ ++/** ++ * @brief Move whatever is readable on @p from to @p to. ++ * ++ * @return 1 while the direction is alive, 0 on EOF or error. ++ */ ++static int ++forward(int from, int to) ++{ ++ char buf[65536]; ++ ssize_t n; ++ ++ n = read(from, buf, sizeof buf); ++ if ((n < 0) && (errno == EINTR)) { ++ return 1; ++ } ++ if (n <= 0) { ++ return 0; ++ } ++ ++ return !write_all(to, buf, n); ++} ++ ++static void ++usage(FILE *fp, const char *prog) ++{ ++ fprintf(fp, "Usage: %s [-h] [-s PATH]\n" ++ "\n" ++ " -h This help text\n" ++ " -s PATH UNIX socket of the NETCONF server, default %s\n", ++ prog, NC_SUBSYSTEM_SOCKET); ++} ++ ++int ++main(int argc, char *argv[]) ++{ ++ const char *path = NC_SUBSYSTEM_SOCKET; ++ struct pollfd pfd[2]; ++ int c, sock, flags; ++ ++ while ((c = getopt(argc, argv, "hs:")) != -1) { ++ switch (c) { ++ case 'h': ++ usage(stdout, argv[0]); ++ return 0; ++ case 's': ++ path = optarg; ++ break; ++ default: ++ usage(stderr, argv[0]); ++ return 1; ++ } ++ } ++ ++ /* a vanished peer is reported by write() instead */ ++ signal(SIGPIPE, SIG_IGN); ++ ++ /* connect and announce our own username, libnetconf2 logs any failure */ ++ sock = nc_proxy_unix_connect(path, NULL); ++ if (sock < 0) { ++ return 1; ++ } ++ ++ /* the proxy leaves the socket non-blocking, we want plain blocking writes */ ++ flags = fcntl(sock, F_GETFL); ++ if ((flags < 0) || (fcntl(sock, F_SETFL, flags & ~O_NONBLOCK) < 0)) { ++ fprintf(stderr, "%s: fcntl failed (%s)\n", argv[0], strerror(errno)); ++ return 1; ++ } ++ ++ pfd[0].fd = STDIN_FILENO; ++ pfd[0].events = POLLIN; ++ pfd[1].fd = sock; ++ pfd[1].events = POLLIN; ++ ++ while (1) { ++ if (poll(pfd, 2, -1) < 0) { ++ if (errno == EINTR) { ++ continue; ++ } ++ break; ++ } ++ ++ /* client to server, on EOF tell the server we are done but keep draining its replies */ ++ if (pfd[0].revents && !forward(STDIN_FILENO, sock)) { ++ shutdown(sock, SHUT_WR); ++ pfd[0].fd = -1; ++ } ++ ++ /* server to client, EOF here ends the session */ ++ if (pfd[1].revents && !forward(sock, STDOUT_FILENO)) { ++ break; ++ } ++ } ++ ++ nc_proxy_unix_close(sock); ++ return 0; ++} +-- +2.43.0 + diff --git a/patches/netopeer2/2.7.0/0005-main-run-behind-an-SSH-daemon-with-U-PATH.patch b/patches/netopeer2/2.7.0/0005-main-run-behind-an-SSH-daemon-with-U-PATH.patch new file mode 100644 index 000000000..d30278369 --- /dev/null +++ b/patches/netopeer2/2.7.0/0005-main-run-behind-an-SSH-daemon-with-U-PATH.patch @@ -0,0 +1,228 @@ +From ef69628385101f3778c670f5936a2bbad8bdef7c Mon Sep 17 00:00:00 2001 +From: Joachim Wiberg +Date: Fri, 25 Sep 2026 08:25:11 +0200 +Subject: [PATCH] main: run behind an SSH daemon with -U PATH +Organization: Wires + +libnetconf2 can now serve NETCONF over a UNIX socket that an SSH daemon +feeds through its netconf-subsystem helper, with no libssh at all. Let +the server take part: a bare path to -U creates that endpoint, and the +ietf-netconf-server module becomes optional, since nothing else needs +it in that setup. + +UNIX sessions now show up in ietf-netconf-monitoring as netconf-ssh, +which is what they are, so kill-session keeps working. Their host is +the socket path, which is no inet:host, so source-host is left out. + +--- + src/common.c | 36 +++++++++++++++++++--------------- + src/common.h | 1 + + src/main.c | 42 +++++++++++++++++++++++++++++++--------- + src/netconf_monitoring.c | 14 +++++++++++++- + 4 files changed, 67 insertions(+), 26 deletions(-) + +diff --git a/src/common.c b/src/common.c +index 1956e3e..c3cda82 100644 +--- a/src/common.c ++++ b/src/common.c +@@ -1141,14 +1141,16 @@ np_op_parse_config(struct lyd_node_any *node, uint32_t parse_options, struct lyd + } + + if (*config) { +- /* get the list of ignored modules, skip NACM */ +- r = sr_get_data(np2srv.sr_sess, "/libnetconf2-netconf-server:ln2-netconf-server/ignored-hello-module", 0, +- np2srv.sr_timeout, 0, &sr_ln2_nc_server); +- if (r == SR_ERR_NOT_FOUND) { +- WRN("Failed to get ignored modules."); +- } else if (r) { +- reply = np_reply_err_sr(np2srv.sr_sess, "get"); +- goto cleanup; ++ /* get the list of ignored modules, skip NACM; the module is optional when only -U PATH is used */ ++ if (ly_ctx_get_module_implemented(ly_ctx, "libnetconf2-netconf-server")) { ++ r = sr_get_data(np2srv.sr_sess, "/libnetconf2-netconf-server:ln2-netconf-server/ignored-hello-module", 0, ++ np2srv.sr_timeout, 0, &sr_ln2_nc_server); ++ if (r == SR_ERR_NOT_FOUND) { ++ WRN("Failed to get ignored modules."); ++ } else if (r) { ++ reply = np_reply_err_sr(np2srv.sr_sess, "get"); ++ goto cleanup; ++ } + } + + if (sr_ln2_nc_server) { +@@ -1282,14 +1284,16 @@ np_op_filter_data_get(sr_session_ctx_t *session, uint32_t max_depth, uint32_t ge + } + + if (sr_data) { +- /* get the list of ignored modules, skip NACM */ +- r = sr_get_data(np2srv.sr_sess, "/libnetconf2-netconf-server:ln2-netconf-server/ignored-hello-module", 0, +- np2srv.sr_timeout, 0, &sr_ln2_nc_server); +- if (r == SR_ERR_NOT_FOUND) { +- WRN("Failed to get ignored modules."); +- } else if (r) { +- reply = np_reply_err_sr(np2srv.sr_sess, "get"); +- goto cleanup; ++ /* get the list of ignored modules, skip NACM; the module is optional when only -U PATH is used */ ++ if (ly_ctx_get_module_implemented(LYD_CTX(sr_data->tree), "libnetconf2-netconf-server")) { ++ r = sr_get_data(np2srv.sr_sess, "/libnetconf2-netconf-server:ln2-netconf-server/ignored-hello-module", 0, ++ np2srv.sr_timeout, 0, &sr_ln2_nc_server); ++ if (r == SR_ERR_NOT_FOUND) { ++ WRN("Failed to get ignored modules."); ++ } else if (r) { ++ reply = np_reply_err_sr(np2srv.sr_sess, "get"); ++ goto cleanup; ++ } + } + if (sr_ln2_nc_server) { + LY_LIST_FOR(lyd_child(sr_ln2_nc_server->tree), ignored_mod) { +diff --git a/src/common.h b/src/common.h +index d9f12c5..e7f79f9 100644 +--- a/src/common.h ++++ b/src/common.h +@@ -72,6 +72,7 @@ struct np2srv { + + const char *server_dir; /**< path to server files (just confirmed commit for the moment) */ + char *url_protocols; /**< list of supported URL protocols */ ++ const char *unix_socket; /**< UNIX socket to listen on without any ietf-netconf-server config (-U PATH) */ + + struct nc_pollsession *nc_ps; /**< libnetconf2 pollsession structure */ + pthread_t workers[NP2SRV_THREAD_COUNT]; /**< worker threads handling sessions */ +diff --git a/src/main.c b/src/main.c +index 7492b43..ae5a3ef 100644 +--- a/src/main.c ++++ b/src/main.c +@@ -498,11 +498,16 @@ np2srv_check_schemas(sr_session_ctx_t *sr_sess) + mod_name = "ietf-yang-library"; + NP2_CHECK_MODULE(mod_name); + +- /* .. ietf-netconf-server */ ++ /* .. ietf-netconf-server, not needed when only listening on a UNIX socket (-U PATH) */ + mod_name = "ietf-netconf-server"; +- NP2_CHECK_MODULE(mod_name); +- NP2_CHECK_FEATURE("ssh-listen"); +- NP2_CHECK_FEATURE("ssh-call-home"); ++ mod = ly_ctx_get_module_implemented(ly_ctx, mod_name); ++ if (mod) { ++ NP2_CHECK_FEATURE("ssh-listen"); ++ NP2_CHECK_FEATURE("ssh-call-home"); ++ } else if (!np2srv.unix_socket) { ++ ERR("Module \"%s\" not implemented in sysrepo.", mod_name); ++ return -1; ++ } + + sr_session_release_context(sr_sess); + return 0; +@@ -836,6 +841,12 @@ server_init(void) + goto error; + } + ++ /* the socket is world-writable, NACM is the access control */ ++ if (np2srv.unix_socket && nc_server_add_unix_endpt("unix", np2srv.unix_socket, 0666)) { ++ ERR("Listening on UNIX socket \"%s\" failed.", np2srv.unix_socket); ++ goto error; ++ } ++ + /* prepare poll session structure for libnetconf2 */ + np2srv.nc_ps = nc_ps_new(); + +@@ -1255,8 +1266,15 @@ server_data_subscribe(void) + /* create keys and certs subscriptions before server configuration, which may already reference them */ + SR_CONFIG_SUBSCR("ietf-keystore", NULL, np2srv_libnetconf2_config_cb); + SR_CONFIG_SUBSCR("ietf-truststore", NULL, np2srv_libnetconf2_config_cb); +- SR_CONFIG_SUBSCR("ietf-netconf-server", NULL, np2srv_libnetconf2_config_cb); +- SR_CONFIG_SUBSCR("libnetconf2-netconf-server", NULL, np2srv_libnetconf2_config_cb); ++ ++ /* ietf-netconf-server is optional when only listening on a UNIX socket (-U PATH) */ ++ ly_ctx = sr_acquire_context(np2srv.sr_conn); ++ mod = ly_ctx_get_module_implemented(ly_ctx, "ietf-netconf-server"); ++ sr_release_context(np2srv.sr_conn); ++ if (mod) { ++ SR_CONFIG_SUBSCR("ietf-netconf-server", NULL, np2srv_libnetconf2_config_cb); ++ SR_CONFIG_SUBSCR("libnetconf2-netconf-server", NULL, np2srv_libnetconf2_config_cb); ++ } + + /* + * ietf-netconf-acm +@@ -1406,6 +1424,8 @@ print_usage(char *progname) + fprintf(stdout, " supporting some extensions such as schema-mount, in which case the ietf-yang-schema-mount\n"); + fprintf(stdout, " operational data are expected to be in the file.\n"); + fprintf(stdout, " -U ENDPT:PATH Set UNIX socket path for a specific endpoint.\n"); ++ fprintf(stdout, " -U PATH Listen on a UNIX socket without any ietf-netconf-server configuration,\n"); ++ fprintf(stdout, " e.g. behind an sshd \"Subsystem netconf\" running netconf-subsystem.\n"); + fprintf(stdout, " -v LEVEL Verbose output level:\n"); + fprintf(stdout, " 0 - errors\n"); + fprintf(stdout, " 1 - errors and warnings\n"); +@@ -1532,11 +1552,15 @@ main(int argc, char *argv[]) + np2srv.ext_data_path = optarg; + break; + case 'U': +- /* parse endpoint_name:unix_socket_path */ ++ /* a bare path is an endpoint of its own, created after nc_server_init() */ + ptr = strchr(optarg, ':'); + if (!ptr) { +- ERR("Invalid format for -U parameter \"%s\". Expected format: :", optarg); +- return EXIT_FAILURE; ++ if (optarg[0] != '/') { ++ ERR("Invalid -U parameter \"%s\". Expected an absolute path or :", optarg); ++ return EXIT_FAILURE; ++ } ++ np2srv.unix_socket = optarg; ++ break; + } + + /* terminate the endpoint name string */ +diff --git a/src/netconf_monitoring.c b/src/netconf_monitoring.c +index 61b3769..b5f364e 100644 +--- a/src/netconf_monitoring.c ++++ b/src/netconf_monitoring.c +@@ -71,6 +71,8 @@ ncm_is_monitored(struct nc_session *session) + case NC_TI_TLS: + return 1; + #endif ++ case NC_TI_UNIX: ++ return 1; + default: + break; + } +@@ -275,6 +277,7 @@ np2srv_ncm_oper_cb(sr_session_ctx_t *session, uint32_t UNUSED(sub_id), const cha + struct ly_ctx *ly_ctx; + char **cpblts; + char *time_str, buf[11]; ++ const char *host; + uint32_t i; + LY_ARRAY_COUNT_TYPE u; + struct timespec ts; +@@ -344,6 +347,7 @@ np2srv_ncm_oper_cb(sr_session_ctx_t *session, uint32_t UNUSED(sub_id), const cha + sprintf(buf, "%" PRIu32, nc_session_get_id(stats.sessions[i])); + lyd_new_list(cont, NULL, "session", 0, &list, buf); + ++ host = nc_session_get_host(stats.sessions[i]); + switch (nc_session_get_ti(stats.sessions[i])) { + #ifdef NC_ENABLED_SSH_TLS + case NC_TI_SSH: +@@ -353,13 +357,21 @@ np2srv_ncm_oper_cb(sr_session_ctx_t *session, uint32_t UNUSED(sub_id), const cha + lyd_new_term(list, NULL, "transport", "netconf-tls", 0, NULL); + break; + #endif ++ case NC_TI_UNIX: ++ /* UNIX sessions come from an SSH daemon running netconf-subsystem, ++ * their host is the socket path, not a valid inet:host */ ++ lyd_new_term(list, NULL, "transport", "netconf-ssh", 0, NULL); ++ host = NULL; ++ break; + default: /* NC_TI_FD, NC_TI_NONE */ + ERR("ietf-netconf-monitoring unsupported session transport type."); + pthread_mutex_unlock(&stats.lock); + goto error; + } + lyd_new_term(list, NULL, "username", nc_session_get_username(stats.sessions[i]), 0, NULL); +- lyd_new_term(list, NULL, "source-host", nc_session_get_host(stats.sessions[i]), 0, NULL); ++ if (host) { ++ lyd_new_term(list, NULL, "source-host", host, 0, NULL); ++ } + ts = nc_session_get_start_time(stats.sessions[i]); + ly_time_ts2str(&ts, &time_str); + lyd_new_term(list, NULL, "login-time", time_str, 0, NULL); +-- +2.43.0 + From 80716e7027231fde8f271efd3f38cb7913ddaeb0 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 25 Sep 2026 10:20:14 +0200 Subject: [PATCH 2/5] confd: move netconf under /services/ssh NETCONF becomes a netconf/enabled toggle under the ssh service, and in the default build depends on it the same way restconf depends on web: confd adds port 830 to the sshd listen addresses, forces the subsystem on that port with a Match block, and starts netopeer2-server with -U on the socket. ietf-netconf-server has nothing left to describe there, so it is not loaded and its factory data goes. The build without the OpenSSH subsystem keeps all of that: confd loads the SSH and TLS modules, ships the endpoint in the factory config, and leaves port 830 to netopeer2-server. The migration therefore comes in two flavours, one converts an old endpoint into netconf/enabled, the other only adds the leaf. sshd runs subsystems through the login shell, so the clish wrapper lets the helper through, and only the helper, when called with -c. Signed-off-by: Joachim Wiberg --- .../etc/factory-config.cfg | 32 ------- .../bananapi,bpi-r3/etc/factory-config.cfg | 32 ------- .../etc/factory-config.cfg | 32 ------- .../etc/factory-config.cfg | 32 ------- .../bananapi,bpi-r4/etc/factory-config.cfg | 32 ------- .../bananapi,bpi-r64/etc/factory-config.cfg | 32 ------- .../etc/factory-config.cfg | 32 ------- .../etc/factory-config.cfg | 32 ------- .../raspberrypi,400/etc/factory-config.cfg | 32 ------- .../etc/factory-config.cfg | 32 ------- board/common/rootfs/usr/bin/clish | 7 ++ package/confd/confd.mk | 28 +++++- .../available => package/confd}/netconf.conf | 0 .../confd/netopeer2.pam | 0 package/confd/sshd-netconf.conf | 1 + src/confd/configure.ac | 8 ++ src/confd/share/factory.d/Makefile.am | 5 +- .../share/failure.d/10-netconf-server.json | 0 src/confd/share/failure.d/Makefile.am | 4 +- .../migrate/1.10/20-netconf-ssh-subsystem.sh | 17 ++++ src/confd/share/migrate/1.10/Makefile.am | 3 + src/confd/share/test.d/10-netconf-server.json | 0 src/confd/share/test.d/Makefile.am | 5 +- src/confd/src/services.c | 87 +++++++++++++++---- src/confd/yang/confd.inc | 2 +- src/confd/yang/confd/infix-services.yang | 22 +++++ ...18.yang => infix-services@2026-09-24.yang} | 0 src/confd/yang/libnetconf2.inc | 13 +-- src/confd/yang/netconf-server.inc | 16 ++++ 29 files changed, 187 insertions(+), 351 deletions(-) rename {board/common/rootfs/etc/finit.d/available => package/confd}/netconf.conf (100%) rename board/common/rootfs/etc/pam.d/netopeer2.conf => package/confd/netopeer2.pam (100%) create mode 100644 package/confd/sshd-netconf.conf mode change 120000 => 100644 src/confd/share/failure.d/10-netconf-server.json create mode 100755 src/confd/share/migrate/1.10/20-netconf-ssh-subsystem.sh mode change 120000 => 100644 src/confd/share/test.d/10-netconf-server.json rename src/confd/yang/confd/{infix-services@2026-09-18.yang => infix-services@2026-09-24.yang} (100%) create mode 100644 src/confd/yang/netconf-server.inc diff --git a/board/aarch64/acer-connect-vero-w6m/rootfs/usr/share/product/acer,connect-vero-w/etc/factory-config.cfg b/board/aarch64/acer-connect-vero-w6m/rootfs/usr/share/product/acer,connect-vero-w/etc/factory-config.cfg index 255879358..49bf5ab8b 100644 --- a/board/aarch64/acer-connect-vero-w6m/rootfs/usr/share/product/acer,connect-vero-w/etc/factory-config.cfg +++ b/board/aarch64/acer-connect-vero-w6m/rootfs/usr/share/product/acer,connect-vero-w/etc/factory-config.cfg @@ -341,38 +341,6 @@ } ] }, - "ietf-netconf-server:netconf-server": { - "listen": { - "endpoints": { - "endpoint": [ - { - "name": "default-ssh", - "ssh": { - "tcp-server-parameters": { - "local-bind": [ - { - "local-address": "::" - } - ] - }, - "ssh-server-parameters": { - "server-identity": { - "host-key": [ - { - "name": "default-key", - "public-key": { - "central-keystore-reference": "genkey" - } - } - ] - } - } - } - } - ] - } - } - }, "ietf-system:system": { "hostname": "acer-connect-%m", "infix-system:software": { diff --git a/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3/etc/factory-config.cfg index dff3ccbd3..5c8691cb0 100644 --- a/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3/etc/factory-config.cfg @@ -334,38 +334,6 @@ } ] }, - "ietf-netconf-server:netconf-server": { - "listen": { - "endpoints": { - "endpoint": [ - { - "name": "default-ssh", - "ssh": { - "tcp-server-parameters": { - "local-bind": [ - { - "local-address": "::" - } - ] - }, - "ssh-server-parameters": { - "server-identity": { - "host-key": [ - { - "name": "default-key", - "public-key": { - "central-keystore-reference": "genkey" - } - } - ] - } - } - } - } - ] - } - } - }, "ietf-system:system": { "hostname": "bpi-%m", "infix-system:software": { diff --git a/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3mini/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3mini/etc/factory-config.cfg index 728bba2e6..00ed9f297 100644 --- a/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3mini/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r3/rootfs/usr/share/product/bananapi,bpi-r3mini/etc/factory-config.cfg @@ -300,38 +300,6 @@ } ] }, - "ietf-netconf-server:netconf-server": { - "listen": { - "endpoints": { - "endpoint": [ - { - "name": "default-ssh", - "ssh": { - "tcp-server-parameters": { - "local-bind": [ - { - "local-address": "::" - } - ] - }, - "ssh-server-parameters": { - "server-identity": { - "host-key": [ - { - "name": "default-key", - "public-key": { - "central-keystore-reference": "genkey" - } - } - ] - } - } - } - } - ] - } - } - }, "ietf-system:system": { "hostname": "bpi-%m", "infix-system:software": { diff --git a/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4-2g5/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4-2g5/etc/factory-config.cfg index 89b3b31fb..552bb4e06 100644 --- a/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4-2g5/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4-2g5/etc/factory-config.cfg @@ -258,38 +258,6 @@ } ] }, - "ietf-netconf-server:netconf-server": { - "listen": { - "endpoints": { - "endpoint": [ - { - "name": "default-ssh", - "ssh": { - "tcp-server-parameters": { - "local-bind": [ - { - "local-address": "::" - } - ] - }, - "ssh-server-parameters": { - "server-identity": { - "host-key": [ - { - "name": "default-key", - "public-key": { - "central-keystore-reference": "genkey" - } - } - ] - } - } - } - } - ] - } - } - }, "ietf-system:system": { "hostname": "bpi-%m", "infix-system:software": { diff --git a/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4/etc/factory-config.cfg index f827fa453..6a71c4df0 100644 --- a/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r4/rootfs/usr/share/product/bananapi,bpi-r4/etc/factory-config.cfg @@ -250,38 +250,6 @@ } ] }, - "ietf-netconf-server:netconf-server": { - "listen": { - "endpoints": { - "endpoint": [ - { - "name": "default-ssh", - "ssh": { - "tcp-server-parameters": { - "local-bind": [ - { - "local-address": "::" - } - ] - }, - "ssh-server-parameters": { - "server-identity": { - "host-key": [ - { - "name": "default-key", - "public-key": { - "central-keystore-reference": "genkey" - } - } - ] - } - } - } - } - ] - } - } - }, "ietf-system:system": { "hostname": "bpi-%m", "infix-system:software": { diff --git a/board/aarch64/bananapi-bpi-r64/rootfs/usr/share/product/bananapi,bpi-r64/etc/factory-config.cfg b/board/aarch64/bananapi-bpi-r64/rootfs/usr/share/product/bananapi,bpi-r64/etc/factory-config.cfg index 235a16da0..f989e5e34 100644 --- a/board/aarch64/bananapi-bpi-r64/rootfs/usr/share/product/bananapi,bpi-r64/etc/factory-config.cfg +++ b/board/aarch64/bananapi-bpi-r64/rootfs/usr/share/product/bananapi,bpi-r64/etc/factory-config.cfg @@ -292,38 +292,6 @@ } ] }, - "ietf-netconf-server:netconf-server": { - "listen": { - "endpoints": { - "endpoint": [ - { - "name": "default-ssh", - "ssh": { - "tcp-server-parameters": { - "local-bind": [ - { - "local-address": "::" - } - ] - }, - "ssh-server-parameters": { - "server-identity": { - "host-key": [ - { - "name": "default-key", - "public-key": { - "central-keystore-reference": "genkey" - } - } - ] - } - } - } - } - ] - } - } - }, "ietf-system:system": { "hostname": "bpi-%m", "infix-system:software": { diff --git a/board/aarch64/friendlyarm-nanopi-r2s/rootfs/usr/share/product/friendlyarm,nanopi-r2s/etc/factory-config.cfg b/board/aarch64/friendlyarm-nanopi-r2s/rootfs/usr/share/product/friendlyarm,nanopi-r2s/etc/factory-config.cfg index b996bd9ae..a90e40c9f 100644 --- a/board/aarch64/friendlyarm-nanopi-r2s/rootfs/usr/share/product/friendlyarm,nanopi-r2s/etc/factory-config.cfg +++ b/board/aarch64/friendlyarm-nanopi-r2s/rootfs/usr/share/product/friendlyarm,nanopi-r2s/etc/factory-config.cfg @@ -234,38 +234,6 @@ } ] }, - "ietf-netconf-server:netconf-server": { - "listen": { - "endpoints": { - "endpoint": [ - { - "name": "default-ssh", - "ssh": { - "tcp-server-parameters": { - "local-bind": [ - { - "local-address": "::" - } - ] - }, - "ssh-server-parameters": { - "server-identity": { - "host-key": [ - { - "name": "default-key", - "public-key": { - "central-keystore-reference": "genkey" - } - } - ] - } - } - } - } - ] - } - } - }, "ietf-system:system": { "hostname": "r2s-%m", "infix-system:software": { diff --git a/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,4-model-b/etc/factory-config.cfg b/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,4-model-b/etc/factory-config.cfg index 55d29970f..1604cd520 100644 --- a/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,4-model-b/etc/factory-config.cfg +++ b/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,4-model-b/etc/factory-config.cfg @@ -211,38 +211,6 @@ } ] }, - "ietf-netconf-server:netconf-server": { - "listen": { - "endpoints": { - "endpoint": [ - { - "name": "default-ssh", - "ssh": { - "tcp-server-parameters": { - "local-bind": [ - { - "local-address": "::" - } - ] - }, - "ssh-server-parameters": { - "server-identity": { - "host-key": [ - { - "name": "default-key", - "public-key": { - "central-keystore-reference": "genkey" - } - } - ] - } - } - } - } - ] - } - } - }, "ietf-system:system": { "hostname": "rpi-%m", "infix-system:software": { diff --git a/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,400/etc/factory-config.cfg b/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,400/etc/factory-config.cfg index 50aee2240..c892abafc 100644 --- a/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,400/etc/factory-config.cfg +++ b/board/aarch64/raspberrypi-rpi64/rootfs/usr/share/product/raspberrypi,400/etc/factory-config.cfg @@ -225,38 +225,6 @@ } ] }, - "ietf-netconf-server:netconf-server": { - "listen": { - "endpoints": { - "endpoint": [ - { - "name": "default-ssh", - "ssh": { - "tcp-server-parameters": { - "local-bind": [ - { - "local-address": "::" - } - ] - }, - "ssh-server-parameters": { - "server-identity": { - "host-key": [ - { - "name": "default-key", - "public-key": { - "central-keystore-reference": "genkey" - } - } - ] - } - } - } - } - ] - } - } - }, "ietf-system:system": { "hostname": "rpi-%m", "infix-system:software": { diff --git a/board/arm/raspberrypi-rpi2/rootfs/usr/share/product/raspberrypi,2-model-b/etc/factory-config.cfg b/board/arm/raspberrypi-rpi2/rootfs/usr/share/product/raspberrypi,2-model-b/etc/factory-config.cfg index 32375ab65..65540241c 100644 --- a/board/arm/raspberrypi-rpi2/rootfs/usr/share/product/raspberrypi,2-model-b/etc/factory-config.cfg +++ b/board/arm/raspberrypi-rpi2/rootfs/usr/share/product/raspberrypi,2-model-b/etc/factory-config.cfg @@ -196,38 +196,6 @@ } ] }, - "ietf-netconf-server:netconf-server": { - "listen": { - "endpoints": { - "endpoint": [ - { - "name": "default-ssh", - "ssh": { - "tcp-server-parameters": { - "local-bind": [ - { - "local-address": "::" - } - ] - }, - "ssh-server-parameters": { - "server-identity": { - "host-key": [ - { - "name": "default-key", - "public-key": { - "central-keystore-reference": "genkey" - } - } - ] - } - } - } - } - ] - } - } - }, "ietf-system:system": { "hostname": "rpi-%m", "infix-system:software": { diff --git a/board/common/rootfs/usr/bin/clish b/board/common/rootfs/usr/bin/clish index ae47aeb06..f39505f29 100755 --- a/board/common/rootfs/usr/bin/clish +++ b/board/common/rootfs/usr/bin/clish @@ -1,3 +1,10 @@ #!/bin/sh +# sshd runs subsystems through the login shell, `$SHELL -c CMD`, so let +# the NETCONF bridge through. Everything else gets the CLI, users with +# this shell must not be able to run arbitrary commands. +if [ "$1" = "-c" ] && [ "$2" = "/usr/libexec/libnetconf2/netconf-subsystem" ]; then + exec "$2" +fi + # Source settings, aliases, and probe terminal size, then hand over to klish exec env CLISH=yes bash -ilc /usr/bin/klish diff --git a/package/confd/confd.mk b/package/confd/confd.mk index 4b9d9bbb3..3c005e233 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -55,8 +55,13 @@ CONFD_CONF_OPTS += --enable-snmp else CONFD_CONF_OPTS += --disable-snmp endif +ifeq ($(BR2_PACKAGE_LIBNETCONF2_SSH_SUBSYSTEM),y) +CONFD_CONF_OPTS += --enable-netconf-subsystem +else +CONFD_CONF_OPTS += --disable-netconf-subsystem +endif define CONFD_INSTALL_EXTRA - for fn in confd.conf crond.conf rcd.conf resolvconf.conf; do \ + for fn in confd.conf crond.conf netconf.conf rcd.conf resolvconf.conf; do \ cp $(CONFD_PKGDIR)/$$fn $(FINIT_D)/available/; \ done for fn in confd.conf rcd.conf resolvconf.conf; do \ @@ -67,6 +72,24 @@ define CONFD_INSTALL_EXTRA cp $(CONFD_PKGDIR)/netconf.service $(TARGET_DIR)/etc/avahi/services/ endef +# NETCONF as an OpenSSH subsystem: sshd runs the libnetconf2 helper, which +# connects to the UNIX socket netopeer2-server listens on. +ifeq ($(BR2_PACKAGE_LIBNETCONF2_SSH_SUBSYSTEM),y) +define CONFD_INSTALL_NETCONF_SUBSYSTEM + $(SED) 's|-v 1 \\|-v 1 -U /run/netconf.sock \\|' $(FINIT_D)/available/netconf.conf + mkdir -p $(TARGET_DIR)/etc/ssh/sshd_config.d + cp $(CONFD_PKGDIR)/sshd-netconf.conf $(TARGET_DIR)/etc/ssh/sshd_config.d/netconf.conf +endef +else +define CONFD_INSTALL_NETCONF_SERVER + cp $(CONFD_PKGDIR)/netopeer2.pam $(TARGET_DIR)/etc/pam.d/netopeer2.conf +endef +define CONFD_INSTALL_YANG_MODULES_NETCONF_SERVER + $(COMMON_SYSREPO_ENV) \ + $(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/netconf-server.inc +endef +endif + NETOPEER2_SEARCHPATH=$(TARGET_DIR)/usr/share/yang/modules/netopeer2/ SYSREPO_SEARCHPATH=$(TARGET_DIR)/usr/share/yang/modules/sysrepo/ LIBNETCONF2_SEARCHPATH=$(TARGET_DIR)/usr/share/yang/modules/libnetconf2/ @@ -151,7 +174,10 @@ endef CONFD_PRE_BUILD_HOOKS += CONFD_EMPTY_SYSREPO CONFD_PRE_BUILD_HOOKS += CONFD_CLEANUP CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_EXTRA +CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_NETCONF_SUBSYSTEM +CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_NETCONF_SERVER CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES +CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_NETCONF_SERVER CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_CONTAINERS CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_WIFI CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_GPS diff --git a/board/common/rootfs/etc/finit.d/available/netconf.conf b/package/confd/netconf.conf similarity index 100% rename from board/common/rootfs/etc/finit.d/available/netconf.conf rename to package/confd/netconf.conf diff --git a/board/common/rootfs/etc/pam.d/netopeer2.conf b/package/confd/netopeer2.pam similarity index 100% rename from board/common/rootfs/etc/pam.d/netopeer2.conf rename to package/confd/netopeer2.pam diff --git a/package/confd/sshd-netconf.conf b/package/confd/sshd-netconf.conf new file mode 100644 index 000000000..1d72e1f46 --- /dev/null +++ b/package/confd/sshd-netconf.conf @@ -0,0 +1 @@ +Subsystem netconf /usr/libexec/libnetconf2/netconf-subsystem diff --git a/src/confd/configure.ac b/src/confd/configure.ac index a6c300840..ed1dc8873 100644 --- a/src/confd/configure.ac +++ b/src/confd/configure.ac @@ -69,6 +69,10 @@ AC_ARG_ENABLE(snmp, AS_HELP_STRING([--enable-snmp], [Enable support for the SNMP agent]),,[ enable_snmp=no]) +AC_ARG_ENABLE(netconf-subsystem, + AS_HELP_STRING([--enable-netconf-subsystem], [NETCONF is an OpenSSH subsystem, not netopeer2 with libssh]),,[ + enable_netconf_subsystem=no]) + AC_ARG_WITH(login-shell, AS_HELP_STRING([--with-login-shell=shell], [Login shell for new users, default: /bin/false]), [login_shell=$withval], [login_shell=yes]) @@ -98,6 +102,9 @@ AS_IF([test "x$enable_webui" = "xyes"], [ AS_IF([test "x$enable_snmp" = "xyes"], [ AC_DEFINE(HAVE_SNMP, 1, [Built with SNMP agent support])]) +AS_IF([test "x$enable_netconf_subsystem" = "xyes"], [ + AC_DEFINE(HAVE_NETCONF_SUBSYSTEM, 1, [NETCONF is served by OpenSSH, not netopeer2 with libssh])]) + AS_IF([test "x$with_login_shell" != "xno"], [ AS_IF([test "x$login_shell" = "xyes"], [login_shell=/bin/false]) AC_DEFINE_UNQUOTED(LOGIN_SHELL, "$login_shell", [Default: /bin/false])],[ @@ -133,6 +140,7 @@ AC_SUBST([EV_LIBS]) # Control build with automake flags AM_CONDITIONAL(CONTAINERS, [test "x$enable_containers" != "xno"]) AM_CONDITIONAL(SNMP, [test "x$enable_snmp" != "xno"]) +AM_CONDITIONAL(NETCONF_SUBSYSTEM, [test "x$enable_netconf_subsystem" != "xno"]) # Plugin installation path for sysrepo-plugind PKG_CHECK_VAR([srpdplugindir], [sysrepo], [SRPD_PLUGINS_PATH]) diff --git a/src/confd/share/factory.d/Makefile.am b/src/confd/share/factory.d/Makefile.am index 3a2e11ad0..3a5c9d60b 100644 --- a/src/confd/share/factory.d/Makefile.am +++ b/src/confd/share/factory.d/Makefile.am @@ -1,5 +1,8 @@ factorydir = $(pkgdatadir)/factory.d dist_factory_DATA = 10-keystore.json 10-nacm.json \ - 10-netconf-server.json \ 10-infix-services.json 10-software.json \ 10-system.json + +if !NETCONF_SUBSYSTEM +dist_factory_DATA += 10-netconf-server.json +endif diff --git a/src/confd/share/failure.d/10-netconf-server.json b/src/confd/share/failure.d/10-netconf-server.json deleted file mode 120000 index 4253be800..000000000 --- a/src/confd/share/failure.d/10-netconf-server.json +++ /dev/null @@ -1 +0,0 @@ -../factory.d/10-netconf-server.json \ No newline at end of file diff --git a/src/confd/share/failure.d/10-netconf-server.json b/src/confd/share/failure.d/10-netconf-server.json new file mode 100644 index 000000000..4253be800 --- /dev/null +++ b/src/confd/share/failure.d/10-netconf-server.json @@ -0,0 +1 @@ +../factory.d/10-netconf-server.json \ No newline at end of file diff --git a/src/confd/share/failure.d/Makefile.am b/src/confd/share/failure.d/Makefile.am index 3b1f5f7da..135cca48c 100644 --- a/src/confd/share/failure.d/Makefile.am +++ b/src/confd/share/failure.d/Makefile.am @@ -1,5 +1,7 @@ failuredir = $(pkgdatadir)/failure.d dist_failure_DATA = 10-keystore.json 10-nacm.json \ - 10-netconf-server.json \ 10-infix-services.json 10-system.json +if !NETCONF_SUBSYSTEM +dist_failure_DATA += 10-netconf-server.json +endif diff --git a/src/confd/share/migrate/1.10/20-netconf-ssh-subsystem.sh b/src/confd/share/migrate/1.10/20-netconf-ssh-subsystem.sh new file mode 100755 index 000000000..13cb4b61c --- /dev/null +++ b/src/confd/share/migrate/1.10/20-netconf-ssh-subsystem.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# NETCONF is served by the SSH daemon as a subsystem and ietf-netconf-server +# is gone. NETCONF is on by default, so only a configuration without a +# NETCONF endpoint needs ssh/netconf/enabled set to false. + +file=$1 +temp=${file}.tmp + +jq ' +(.["ietf-netconf-server:netconf-server"]?.listen?.endpoints?.endpoint // [] | length > 0) as $netconf | +del(.["ietf-netconf-server:netconf-server"]) | +if $netconf then + . +else + .["infix-services:ssh"].netconf.enabled = false +end +' "$file" > "$temp" && mv "$temp" "$file" diff --git a/src/confd/share/migrate/1.10/Makefile.am b/src/confd/share/migrate/1.10/Makefile.am index 07782eedc..0c8ef8522 100644 --- a/src/confd/share/migrate/1.10/Makefile.am +++ b/src/confd/share/migrate/1.10/Makefile.am @@ -1,2 +1,5 @@ migratedir = $(pkgdatadir)/migrate/1.10 dist_migrate_DATA = 10-software-update-url.sh +if NETCONF_SUBSYSTEM +dist_migrate_DATA += 20-netconf-ssh-subsystem.sh +endif diff --git a/src/confd/share/test.d/10-netconf-server.json b/src/confd/share/test.d/10-netconf-server.json deleted file mode 120000 index 4253be800..000000000 --- a/src/confd/share/test.d/10-netconf-server.json +++ /dev/null @@ -1 +0,0 @@ -../factory.d/10-netconf-server.json \ No newline at end of file diff --git a/src/confd/share/test.d/10-netconf-server.json b/src/confd/share/test.d/10-netconf-server.json new file mode 100644 index 000000000..4253be800 --- /dev/null +++ b/src/confd/share/test.d/10-netconf-server.json @@ -0,0 +1 @@ +../factory.d/10-netconf-server.json \ No newline at end of file diff --git a/src/confd/share/test.d/Makefile.am b/src/confd/share/test.d/Makefile.am index 66ac915fc..cd10e2beb 100644 --- a/src/confd/share/test.d/Makefile.am +++ b/src/confd/share/test.d/Makefile.am @@ -1,4 +1,7 @@ testdir = $(pkgdatadir)/test.d -dist_test_DATA = 10-keystore.json 10-nacm.json 10-netconf-server.json \ +dist_test_DATA = 10-keystore.json 10-nacm.json \ 10-infix-services.json 10-system.json +if !NETCONF_SUBSYSTEM +dist_test_DATA += 10-netconf-server.json +endif diff --git a/src/confd/src/services.c b/src/confd/src/services.c index 593690067..b1281f85d 100644 --- a/src/confd/src/services.c +++ b/src/confd/src/services.c @@ -49,6 +49,12 @@ static const int have_webui = 1; #else static const int have_webui = 0; #endif +/* NETCONF is an sshd subsystem, or netopeer2-server has its own SSH transport */ +#ifdef HAVE_NETCONF_SUBSYSTEM +static const int netconf_subsystem = 1; +#else +static const int netconf_subsystem = 0; +#endif #define FOREACH_SVC(SVC) \ SVC(none) \ @@ -64,8 +70,12 @@ static const int have_webui = 0; #define SSHD_CONFIG_BASE SSH_BASE "/sshd_config.d" #define SSHD_CONFIG_LISTEN SSHD_CONFIG_BASE "/listen.conf" #define SSHD_CONFIG_HOSTKEY SSHD_CONFIG_BASE "/host-keys.conf" +#define SSHD_CONFIG_NETCONF SSHD_CONFIG_BASE "/zz-netconf.conf" +#define NETCONF_SUBSYSTEM "/usr/libexec/libnetconf2/netconf-subsystem" +#define NETCONF_PORT 830 #define LLDP_XPATH "/ieee802-dot1ab-lldp:lldp" #define SSH_XPATH "/infix-services:ssh" +#define SSH_NETCONF_XPATH SSH_XPATH "/netconf" #define MDNS_XPATH "/infix-services:mdns" #define WEB_XPATH "/infix-services:web" #define WEB_RESTCONF_XPATH WEB_XPATH"/restconf" @@ -615,22 +625,29 @@ static int ssh_change(sr_session_ctx_t *session, struct lyd_node *config, struct { struct lyd_node *ssh = NULL, *listen, *host_key; sr_error_t rc = SR_ERR_OK; + int ssh_ena, nc_ena, sshd_ena, keys = 0, addrs = 0; FILE *fp; if (diff && !lydx_get_xpathf(diff, SSH_XPATH)) return SR_ERR_OK; + ssh = lydx_get_xpathf(config, SSH_XPATH); + ssh_ena = lydx_is_enabled(ssh, "enabled"); + nc_ena = lydx_is_enabled(lydx_get_child(ssh, "netconf"), "enabled"); + + /* As a subsystem NETCONF keeps sshd running on port 830 without SSH logins */ + sshd_ena = ssh_ena || (netconf_subsystem && nc_ena); + switch (event) { case SR_EV_DONE: - { - struct lyd_node *ssh = lydx_get_xpathf(config, SSH_XPATH); - int ssh_ena = lydx_is_enabled(ssh, "enabled"); - - if (lydx_get_xpathf(diff, SSH_XPATH "/enabled")) - ssh_ena ? finit_enable("sshd") : finit_disable("sshd"); - else if (ssh_ena) - finit_reload("sshd"); + if (sshd_ena) { + finit_enable("sshd"); + finit_reload("sshd"); + } else { + finit_disable("sshd"); } + if (lydx_get_xpathf(diff, SSH_NETCONF_XPATH "/enabled")) + svc_enable(nc_ena, netconf, NULL); return SR_ERR_OK; case SR_EV_ENABLED: case SR_EV_CHANGE: @@ -641,9 +658,7 @@ static int ssh_change(sr_session_ctx_t *session, struct lyd_node *config, struct return SR_ERR_OK; } - ssh = lydx_get_xpathf(config, SSH_XPATH); - - if (!lydx_is_enabled(ssh, "enabled")) { + if (!sshd_ena) { goto out; } @@ -653,12 +668,16 @@ static int ssh_change(sr_session_ctx_t *session, struct lyd_node *config, struct goto out; } - LY_LIST_FOR(lydx_get_child(ssh, "hostkey"), host_key) { + LYX_LIST_FOR_EACH(lyd_child(ssh), host_key, "hostkey") { const char *keyname = lyd_get_value(host_key); if (!keyname) continue; fprintf(fp, "HostKey %s/hostkeys/%s\n", SSH_BASE, keyname); + keys++; } + /* hostkey is only mandatory with SSH logins enabled, NETCONF still needs one */ + if (!keys) + fprintf(fp, "HostKey %s/hostkeys/genkey\n", SSH_BASE); fclose(fp); @@ -668,16 +687,54 @@ static int ssh_change(sr_session_ctx_t *session, struct lyd_node *config, struct goto out; } - LY_LIST_FOR(lydx_get_child(ssh, "listen"), listen) { + LYX_LIST_FOR_EACH(lyd_child(ssh), listen, "listen") { const char *address, *port; - int ipv6; + struct ly_set *same; + int ipv6, first; address = lydx_get_cattr(listen, "address"); ipv6 = !!strchr(address, ':'); port = lydx_get_cattr(listen, "port"); - fprintf(fp, "ListenAddress %s%s%s:%s\n", ipv6 ? "[" : "", address, ipv6 ? "]" : "", port); + if (ssh_ena) + fprintf(fp, "ListenAddress %s%s%s:%s\n", ipv6 ? "[" : "", address, ipv6 ? "]" : "", port); + if (!netconf_subsystem || !nc_ena) + continue; + + /* NETCONF on port 830 on the same addresses, once per address */ + same = lydx_find_xpathf(ssh, "listen[address='%s']", address); + first = same && same->dnodes[0] == listen; + ly_set_free(same, NULL); + if (first) { + fprintf(fp, "ListenAddress %s%s%s:%d\n", ipv6 ? "[" : "", address, ipv6 ? "]" : "", NETCONF_PORT); + addrs++; + } } + /* Without listen entries sshd would fall back to port 22 on all addresses */ + if (netconf_subsystem && nc_ena && !addrs) + fprintf(fp, "ListenAddress 0.0.0.0:%d\nListenAddress [::]:%d\n", NETCONF_PORT, NETCONF_PORT); + fclose(fp); + + /* + * Port 830 is NETCONF only. A Match block swallows every later + * Include, so this file must sort last in sshd_config.d/. + */ + if (!netconf_subsystem || !nc_ena) { + erase(SSHD_CONFIG_NETCONF); + goto out; + } + + fp = fopen(SSHD_CONFIG_NETCONF, "w"); + if (!fp) { + rc = SR_ERR_INTERNAL; + goto out; + } + + fprintf(fp, "Match LocalPort %d\n" + "\tForceCommand %s\n" + "\tPermitTTY no\n" + "\tAllowTcpForwarding no\n" + "\tX11Forwarding no\n", NETCONF_PORT, NETCONF_SUBSYSTEM); fclose(fp); out: diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index db00e3b45..8af940965 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -42,7 +42,7 @@ MODULES=( "infix-firewall-services@2025-04-26.yang" "infix-firewall-icmp-types@2025-04-26.yang" "infix-meta@2025-12-10.yang" - "infix-services@2026-09-18.yang" + "infix-services@2026-09-24.yang" "infix-system@2026-09-22.yang" "ieee802-ethernet-interface@2025-09-10.yang" "ieee802-ethernet-phy-type@2025-09-10.yang" diff --git a/src/confd/yang/confd/infix-services.yang b/src/confd/yang/confd/infix-services.yang index 410c92564..c871905e6 100644 --- a/src/confd/yang/confd/infix-services.yang +++ b/src/confd/yang/confd/infix-services.yang @@ -31,6 +31,11 @@ module infix-services { contact "kernelkit@googlegroups.com"; description "Infix services, generic."; + revision 2026-09-24 { + description "Add netconf container to ssh, NETCONF is now an SSH subsystem + served by the SSH daemon on port 830."; + reference "internal"; + } revision 2026-09-18 { description "Add TFTP server."; reference "internal"; @@ -279,6 +284,23 @@ module infix-services { error-message "Both address and port must be configured"; } } + + container netconf { + description "NETCONF over SSH, RFC 6242, on port 830. + + In the default build the SSH daemon serves it, on the same + addresses as the listen entries above and as the 'netconf' + subsystem on all other SSH ports. It keeps the SSH daemon + running on port 830 also when SSH logins are disabled. Builds + where netopeer2-server has its own SSH transport configure the + endpoint in ietf-netconf-server."; + + leaf enabled { + description "Enable or disable the NETCONF server."; + type boolean; + default true; + } + } } container web { diff --git a/src/confd/yang/confd/infix-services@2026-09-18.yang b/src/confd/yang/confd/infix-services@2026-09-24.yang similarity index 100% rename from src/confd/yang/confd/infix-services@2026-09-18.yang rename to src/confd/yang/confd/infix-services@2026-09-24.yang diff --git a/src/confd/yang/libnetconf2.inc b/src/confd/yang/libnetconf2.inc index 3f0080b1e..4bc87475d 100644 --- a/src/confd/yang/libnetconf2.inc +++ b/src/confd/yang/libnetconf2.inc @@ -1,12 +1,9 @@ # -*- sh -*- # Modules from libnetconf2 -# INFO: CHANGED FEATURE FLAGS FROM ORIGINAL: ietf-keystore and ietf-ssh-server +# INFO: CHANGED FEATURE FLAGS FROM ORIGINAL: ietf-keystore +# NETCONF is an sshd subsystem, the SSH/TLS transport modules are not loaded MODULES=( - "iana-ssh-encryption-algs@2024-10-16.yang" - "iana-ssh-key-exchange-algs@2024-10-16.yang" - "iana-ssh-mac-algs@2024-10-16.yang" - "iana-ssh-public-key-algs@2024-10-16.yang" "iana-tls-cipher-suite-algs@2024-10-16.yang" "ietf-x509-cert-to-name@2014-12-10.yang" "iana-crypt-hash@2014-04-04.yang -e crypt-hash-md5 -e crypt-hash-sha-256 -e crypt-hash-sha-512" @@ -14,12 +11,6 @@ MODULES=( "ietf-keystore@2024-10-10.yang -e central-keystore-supported -e inline-definitions-supported -e asymmetric-keys" "ietf-truststore@2024-10-10.yang -e central-truststore-supported -e inline-definitions-supported -e certificates -e public-keys" "ietf-tcp-common@2024-10-10.yang -e keepalives-supported" - "ietf-tcp-server@2024-10-10.yang -e tcp-server-keepalives" "ietf-tcp-client@2024-10-10.yang -e local-binding-supported -e tcp-client-keepalives" - "ietf-ssh-common@2024-10-10.yang -e algorithm-discovery -e transport-params" - "ietf-ssh-server@2024-10-10.yang" "ietf-tls-common@2024-10-10.yang -e algorithm-discovery -e tls12 -e tls13 -e hello-params" - "ietf-tls-server@2024-10-10.yang -e server-ident-x509-cert -e client-auth-supported -e client-auth-x509-cert" - "ietf-netconf-server@2025-04-24.yang -e ssh-listen -e tls-listen -e ssh-call-home -e tls-call-home -e central-netconf-server-supported" - "libnetconf2-netconf-server@2025-11-11.yang" ) diff --git a/src/confd/yang/netconf-server.inc b/src/confd/yang/netconf-server.inc new file mode 100644 index 000000000..ec99cd5e9 --- /dev/null +++ b/src/confd/yang/netconf-server.inc @@ -0,0 +1,16 @@ +# -*- sh -*- +# SSH and TLS transports of libnetconf2, when netopeer2-server owns NETCONF +# itself instead of running as an OpenSSH subsystem. + +MODULES=( + "iana-ssh-encryption-algs@2024-10-16.yang" + "iana-ssh-key-exchange-algs@2024-10-16.yang" + "iana-ssh-mac-algs@2024-10-16.yang" + "iana-ssh-public-key-algs@2024-10-16.yang" + "ietf-tcp-server@2024-10-10.yang -e tcp-server-keepalives" + "ietf-ssh-common@2024-10-10.yang -e algorithm-discovery -e transport-params" + "ietf-ssh-server@2024-10-10.yang" + "ietf-tls-server@2024-10-10.yang -e server-ident-x509-cert -e client-auth-supported -e client-auth-x509-cert" + "ietf-netconf-server@2025-04-24.yang -e ssh-listen -e tls-listen -e ssh-call-home -e tls-call-home -e central-netconf-server-supported" + "libnetconf2-netconf-server@2025-11-11.yang" +) From 39304a614dc720fff9f35abebf5845b07e067f59 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 25 Sep 2026 10:20:15 +0200 Subject: [PATCH 3/5] doc: run netconf as an ssh subsystem Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 13 ++++++++----- doc/management.md | 6 ++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index d54c11b05..22c38fcff 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -24,11 +24,8 @@ All notable changes to the project are documented in this file. than by running the tool as root - The CLI `dir` command lists directories as the logged-in user, so it shows only what that user may read - -### Added - -- The CLI accepts an unambiguous prefix of a command name, e.g. `sh int` - for `show interface` +- The CLI now accepts an unambiguous prefix of a command name, e.g., + `sh int` for `show interface` - The CLI accepts IP addresses in CIDR notation, e.g. `set ipv4 address 192.168.1.1/24`. An IPv4 address set without a prefix length gets the classful default: /8, /16, or /24 @@ -106,6 +103,12 @@ All notable changes to the project are documented in this file. - Add Novarq Tactical 1000 (Laguna) support: LAN9696 switch with 24 GbE copper ports, four SFP+ cages, and a management port. Infix bootloader in eMMC, the OS netboots; no eMMC image of the OS yet +- NETCONF is now served by the OpenSSH daemon, as an SSH subsystem on port + 830. This means the `ietf-netconf-server.yang` model is gone and NETCONF + service is now enabled with `ssh/netconf/enabled`, independently of SSH + logins. Existing configurations are migrated. NETCONF call-home, NETCONF + over TLS, and the on-device `netopeer2-cli` tool require the built-in SSH + server of netopeer2 and are therefore no longer available in default builds ### Fixes diff --git a/doc/management.md b/doc/management.md index 677fd9961..411f6ce1a 100644 --- a/doc/management.md +++ b/doc/management.md @@ -40,6 +40,12 @@ admin@example:/config/ssh/listen/ipv4/> set port 12345 admin@example:/config/ssh/listen/ipv4/> +NETCONF is served by the same SSH daemon, as the `netconf` subsystem on +port 830, which can also be reached on the regular SSH port(s) with +`ssh -s example.local netconf`. It is possible to also build the system +to have the `netopeer2-server` listen on port 830 itself, with any TLS +or call-home settings configured in `ietf-netconf-server.yang`. + The default SSH hostkey is generated on first boot and is used in both SSH and NETCONF (SSH transport). Custom keys can be added to the configuration in `ietf-keystore`. The only supported hostkey type is From 7df862bac24b82470dc297ce126521c741e21147 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 27 Sep 2026 18:40:53 +0200 Subject: [PATCH 4/5] sshd: drop the syslogd condition syslogd starts in runlevel S and sshd not before runlevel 2, so the condition never held anything back. It did cost us: a configuration change that touches both, a hostname change together with an SSH change, reloads syslogd, which puts the condition in flux and pauses sshd; with Finit 4.x a second reload arriving right then loses sshd's pending reload, and it keeps its old listen addresses. NETCONF over sshd makes that a lockout. Signed-off-by: Joachim Wiberg --- .../skeleton/etc/finit.d/available/sshd.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/skeleton-init-finit/skeleton/etc/finit.d/available/sshd.conf b/package/skeleton-init-finit/skeleton/etc/finit.d/available/sshd.conf index 4ae528f64..63c97dc88 100644 --- a/package/skeleton-init-finit/skeleton/etc/finit.d/available/sshd.conf +++ b/package/skeleton-init-finit/skeleton/etc/finit.d/available/sshd.conf @@ -1,2 +1,2 @@ -service env:-/etc/default/sshd \ +service env:-/etc/default/sshd \ [2345] /usr/sbin/sshd -D $SSHD_OPTS -- OpenSSH daemon From ff4f6ef34b07932a060ad8fb52830a0ff4f929f9 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 27 Sep 2026 18:42:36 +0200 Subject: [PATCH 5/5] package/finit: backport pending-reload fix A service paused by a condition during a reload lost its own pending reload if another reload came in before it resumed. With NETCONF on sshd this shows up as a lockout: a hostname change together with an SSH change leaves sshd on its old listen addresses. Upstream commit f6b394e0 on the 4.x branch. Signed-off-by: Joachim Wiberg --- ...ending-reload-across-a-second-conf-r.patch | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 package/finit/0003-service-keep-a-pending-reload-across-a-second-conf-r.patch diff --git a/package/finit/0003-service-keep-a-pending-reload-across-a-second-conf-r.patch b/package/finit/0003-service-keep-a-pending-reload-across-a-second-conf-r.patch new file mode 100644 index 000000000..a2bc545fb --- /dev/null +++ b/package/finit/0003-service-keep-a-pending-reload-across-a-second-conf-r.patch @@ -0,0 +1,47 @@ +From f6b394e0a237c23839577a27272549283dac985b Mon Sep 17 00:00:00 2001 +From: Joachim Wiberg +Date: Sun, 27 Sep 2026 18:40:44 +0200 +Subject: [PATCH] service: keep a pending reload across a second conf reload +Organization: Wires + +A service whose condition goes into flux during a reload is paused +with its reload still pending. If another reload was requested in +the meantime, re-parsing its unchanged .conf file cleared the pending +mark, so the service was resumed without ever being reloaded. Seen +with sshd on Infix, where a configuration change that +touched both landed as two reloads in a row and sshd kept its old +listen addresses. + +The mark is only ever cleared once the change has been applied, so a +mark that is still set when the file is parsed again means exactly +that: not applied yet. Leave it alone. + +--- + src/service.c | 9 ++++++--- + 1 file changed, 6 insertions(+), 3 deletions(-) + +diff --git a/src/service.c b/src/service.c +index d31ec3e7..95cd932f 100644 +--- a/src/service.c ++++ b/src/service.c +@@ -2337,11 +2337,14 @@ int service_register(int type, char *cfg, struct rlimit rlimit[], char *file) + if (cgroup) + parse_cgroup(svc, cgroup); + +- /* New, recently modified or unchanged ... used on reload. */ ++ /* ++ * New or modified since the last reload. The mark is cleared when ++ * the change has been applied, on start or reload, so one that is ++ * still set here is a change that has not been applied yet, e.g. ++ * the service is paused waiting for a condition. Leave it. ++ */ + if ((file && conf_changed(file)) || conf_changed(svc_getenv(svc)) || svc->args_dirty) + svc_mark_dirty(svc); +- else +- svc_mark_clean(svc); + + svc_enable(svc); + +-- +2.43.0 +