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/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/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
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/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
+
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
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
+
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"
+)