Skip to content

Commit ee712d5

Browse files
Peter Hillydnath
authored andcommitted
feat: negotiate NETCONF capabilities and type rpc-errors
Teach sessions to negotiate the shared NETCONF base capability and gate operations against what the server actually advertises. Add structured RpcErrorException and ValidateException flows, harden rpc-error parsing for namespaced replies, and update commit/load/validate behavior to preserve server detail. Back the change with unit and integration coverage, Javadocs for the new public API, and a sanitized integration runner/docs path story.
1 parent 71c2e47 commit ee712d5

21 files changed

Lines changed: 2202 additions & 298 deletions

.gitignore

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ target
1414
*.iml
1515
.idea/
1616
.gradle
17+
.gradle-user-home/
1718
gradle/*
1819
!gradle/wrapper/
1920
!gradle/wrapper/gradle-wrapper.jar
@@ -27,4 +28,12 @@ log-test/
2728

2829
.DS_Store
2930
.factorypath
30-
settings.json
31+
settings.json
32+
33+
# Generated integration-test container image artifacts
34+
/src/test/resources/*-docker-*.tgz
35+
/src/test/resources/manifest.json
36+
/src/test/resources/repositories
37+
/src/test/resources/[0-9a-f]*.json
38+
/src/test/resources/[0-9a-f]*.tar
39+
/src/test/resources/[0-9a-f]*/

README.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ mvn clean package
3434
```
3535
(The wrapper script downloads the correct Gradle version automatically.)
3636

37+
To run the live NETCONF integration suite with Gradle:
38+
39+
```bash
40+
NETCONF_HOST=192.168.1.1 \
41+
NETCONF_USERNAME=admin \
42+
NETCONF_PASSWORD=secret \
43+
NETCONF_PORT=830 \
44+
./gradlew integrationTest
45+
```
46+
3747
Releases
3848
========
3949
Releases contain source code only. Due to changing JDK licensing, jar files are not released.
@@ -47,7 +57,7 @@ User may download the source code and compile it with desired JDK version.
4757
* Instructions to build using `mvn`
4858
* Download Source Code for the required release
4959
* Compile the code and build the jar using `mvn package`
50-
* Use the jar file from (source to netconf-java)/netconf-java/target
60+
* Use the jar file from `./target/`
5161
* Use `mvn versions:display-dependency-updates` to identify possible target versions for dependencies
5262

5363
=======
@@ -56,11 +66,17 @@ v2.2.1
5666
------
5767
* Hardened NETCONF XML parsing against XXE and DTD-based attacks
5868
* Fixed NETCONF RPC framing and `message-id` reply correlation for sequential session reuse
69+
* Enforced shared NETCONF base capability negotiation and derive session framing from the negotiated base version
70+
* Capability-gated candidate, validate, and confirmed-commit operations before sending RPCs
71+
* Added negotiated capability inspection via `Device.getNegotiatedCapabilities()` and `NetconfSession.getNegotiatedCapabilities()`
72+
* Typed NETCONF `<rpc-error>` replies as structured exceptions so callers can inspect server-reported error details
73+
* Added `ValidateException` and clarified `validate()` semantics: server `rpc-error` replies throw, while warning-only or other non-`<ok/>` non-error replies still return `false`
5974
* Improved SSH/NETCONF session cleanup on failed connection or session initialization
6075
* Fixed shell exec helpers so commands are set, channels are connected, and timeout/cleanup behavior is more predictable
6176
* Fixed nested XML path construction in the XML helper
6277
* Documented `NetconfSession` as a sequential request/response channel rather than a concurrent in-flight RPC transport
6378
* Added [`docs/compatibility.md`](docs/compatibility.md) with current RFC, capability, NMDA, and extension support details
79+
* Added a dedicated Gradle `integrationTest` task that forwards NETCONF connection settings for live-server testing
6480
* Upgraded `assertj-core` to `3.27.7` to address `CVE-2026-24400`
6581

6682
v2.2.0
@@ -107,6 +123,7 @@ SYNOPSIS
107123
import java.io.IOException;
108124
import javax.xml.parsers.ParserConfigurationException;
109125
import net.juniper.netconf.NetconfException;
126+
import net.juniper.netconf.ValidateException;
110127
import org.xml.sax.SAXException;
111128

112129
import net.juniper.netconf.XML;
@@ -142,12 +159,30 @@ public class ShowInterfaces {
142159
}
143160
```
144161

162+
Candidate validate example:
163+
164+
```Java
165+
try {
166+
boolean clean = device.validate();
167+
if (!clean) {
168+
// Warning-only or other non-error, non-<ok/> reply.
169+
System.out.println("Validate completed without rpc-error, but did not return <ok/>");
170+
}
171+
} catch (ValidateException e) {
172+
// Server returned one or more <rpc-error> elements.
173+
System.err.println("Validate failed: " + e.getMessage());
174+
e.getRpcErrors().forEach(System.err::println);
175+
}
176+
```
177+
145178
Recommended usage:
146179

147180
* Build one `Device` per target connection and use `try-with-resources` so SSH resources are released predictably.
148181
* Call `connect()` before issuing RPCs. If `connect()` throws, no usable NETCONF session was established.
182+
* Inspect `getNegotiatedCapabilities()` after `connect()` if your application needs to branch on server support for candidate, validate, or confirmed-commit behavior.
149183
* Set `connectionTimeout` and `commandTimeout` explicitly for production use rather than relying on defaults.
150184
* Prefer NETCONF RPC helpers (`executeRPC`, `getConfig`, `loadXMLConfiguration`, `commit`, and friends) for device operations; use shell helpers only for device-specific workflows that are not available over NETCONF.
185+
* Treat `ValidateException` as the server-side `rpc-error` path for `validate()`. A `false` return now means the reply was non-error but not a clean `<ok/>`, typically warnings.
151186
* Shell helper reads are bounded by `commandTimeout`. If you use `runShellCommandRunning(...)`, always close the returned reader so the underlying exec channel is released.
152187

153188
LICENSE

build.gradle

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,73 @@ test {
5050
}
5151
}
5252

53+
def netconfPropertySpecs = [
54+
[name: 'netconf.host', env: 'NETCONF_HOST', required: true],
55+
[name: 'netconf.username', env: 'NETCONF_USERNAME', required: true],
56+
[name: 'netconf.password', env: 'NETCONF_PASSWORD', required: true],
57+
[name: 'netconf.port', env: 'NETCONF_PORT', required: false, defaultValue: '830'],
58+
[name: 'netconf.timeout', env: 'NETCONF_TIMEOUT', required: false, defaultValue: '30000']
59+
]
60+
61+
def resolveNetconfProperty = { Map spec ->
62+
if (project.hasProperty(spec.name)) {
63+
return project.property(spec.name).toString()
64+
}
65+
if (System.getProperty(spec.name) != null) {
66+
return System.getProperty(spec.name)
67+
}
68+
if (System.getenv(spec.env) != null) {
69+
return System.getenv(spec.env)
70+
}
71+
return spec.defaultValue
72+
}
73+
74+
tasks.register('integrationTest', org.gradle.api.tasks.testing.Test) {
75+
description = 'Runs live NETCONF integration tests against a configured endpoint.'
76+
group = 'verification'
77+
useJUnitPlatform()
78+
shouldRunAfter test
79+
testClassesDirs = sourceSets.test.output.classesDirs
80+
classpath = sourceSets.test.runtimeClasspath
81+
82+
filter {
83+
includeTestsMatching 'net.juniper.netconf.integration.NetconfIntegrationTest'
84+
}
85+
86+
testLogging {
87+
events "passed", "skipped", "failed"
88+
}
89+
90+
doFirst {
91+
def resolvedProperties = [:]
92+
def missingRequiredProperties = []
93+
94+
netconfPropertySpecs.each { spec ->
95+
def value = resolveNetconfProperty(spec)
96+
if (value == null || value.toString().trim().isEmpty()) {
97+
if (spec.required) {
98+
missingRequiredProperties << "${spec.name} (${spec.env})"
99+
}
100+
return
101+
}
102+
resolvedProperties[spec.name] = value.toString()
103+
}
104+
105+
if (!missingRequiredProperties.isEmpty()) {
106+
throw new org.gradle.api.GradleException(
107+
"integrationTest requires live NETCONF connection details. " +
108+
"Provide ${missingRequiredProperties.join(', ')} via -Dnetconf.* " +
109+
"or NETCONF_* environment variables."
110+
)
111+
}
112+
113+
systemProperty 'netconf.integration.enabled', 'true'
114+
resolvedProperties.each { propertyName, propertyValue ->
115+
systemProperty propertyName, propertyValue
116+
}
117+
}
118+
}
119+
53120
tasks.withType(JavaCompile).configureEach {
54121
options.fork = true
55122
options.forkOptions.jvmArgs += [

docs/compatibility.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This document describes what `netconf-java` currently implements. It is an implementation matrix, not a blanket compliance claim. Interoperability still depends on the server's advertised capabilities and on whether the application stays within the library's supported session model.
44

5-
Mental model: today the library is a synchronous, JSch-backed NETCONF-over-SSH client. Its strongest path is one sequential RPC conversation per `NetconfSession`, with explicit timeouts and explicit cleanup. Core NETCONF 1.0 and 1.1 framing is supported, several Junos workflows are wrapped ergonomically, and the main standards gap is that optional features are not yet enforced uniformly through capability negotiation.
5+
Mental model: today the library is a synchronous, JSch-backed NETCONF-over-SSH client. Its strongest path is one sequential RPC conversation per `NetconfSession`, with explicit timeouts and explicit cleanup. Core NETCONF 1.0 and 1.1 framing is supported, several Junos workflows are wrapped ergonomically, and optional features are beginning to be enforced through capability negotiation, though coverage is not yet uniform across all extensions.
66

77
## Status legend
88

@@ -18,9 +18,9 @@ Mental model: today the library is a synchronous, JSch-backed NETCONF-over-SSH c
1818
| Standard / feature | Status | Notes |
1919
| --- | --- | --- |
2020
| RFC 6242 SSH subsystem transport | `Supported` | `Device` opens an SSH subsystem channel with `subsystem=netconf` over JSch. |
21-
| RFC 6241 `<hello>` parsing and generation | `Supported with caveats` | `Hello` parsing/building works, and `Hello.builder()` auto-adds `urn:ietf:params:netconf:base:1.1`. The library does not currently fail the session if the peers do not share a common base capability. |
21+
| RFC 6241 `<hello>` parsing and generation | `Supported with caveats` | `Hello` parsing/building works, `Hello.builder()` auto-adds `urn:ietf:params:netconf:base:1.1`, and session establishment now fails if the peers do not share a common NETCONF base capability. The remaining caveat is that the client still cannot intentionally advertise only `base:1.0`. |
2222
| NETCONF 1.0 end-of-message framing (`]]>]]>`) | `Supported` | Legacy framing is still supported for both outbound and inbound messages. |
23-
| NETCONF 1.1 chunked framing | `Supported` | Chunked framing is enabled when the server `<hello>` advertises `urn:ietf:params:netconf:base:1.1`. |
23+
| NETCONF 1.1 chunked framing | `Supported` | Chunked framing is selected when both peers share `urn:ietf:params:netconf:base:1.1`. |
2424
| NETCONF 1.0 server interoperability | `Supported with caveats` | A 1.0-only server can interoperate because the client still advertises `base:1.0` and can read/write legacy framing. |
2525
| NETCONF 1.0-only client advertisement | `Not implemented` | The client cannot intentionally advertise only `base:1.0`; `Hello.builder()` always injects `base:1.1`. |
2626
| RPC `message-id` generation and reply correlation | `Supported with caveats` | Missing `message-id` attributes are injected, replies are validated, and sequential same-session alignment is covered by tests. One `NetconfSession` is still a sequential conversation, not a safe multiplexed channel for concurrent in-flight RPCs. |
@@ -35,11 +35,11 @@ Mental model: today the library is a synchronous, JSch-backed NETCONF-over-SSH c
3535
| --- | --- | --- |
3636
| `<get>` | `Supported` | `getRunningConfigAndState(...)` issues `<get>`. |
3737
| `<get-config>` | `Supported` | Candidate and running helpers exist. |
38-
| `<edit-config>` to candidate | `Supported with caveats` | `loadXMLConfiguration(...)` and `loadTextConfiguration(...)` target `candidate`. The API does not expose `test-option`, `error-option`, or capability-gated behavior checks before use. |
39-
| `:candidate:1.0` | `Supported with caveats` | Candidate-oriented helpers are a primary workflow, but the default client capability still uses the legacy `urn:ietf:params:netconf:base:1.0#candidate` form rather than the RFC 6241 `urn:ietf:params:netconf:capability:candidate:1.0` URN. |
38+
| `<edit-config>` to candidate | `Supported with caveats` | `loadXMLConfiguration(...)` and `loadTextConfiguration(...)` target `candidate`, and candidate-dependent operations now fail locally when the server did not advertise candidate support. The API still does not expose `test-option` or `error-option`. |
39+
| `:candidate:1.0` | `Supported with caveats` | Candidate-oriented helpers are a primary workflow and are now runtime-gated against the server `<hello>`, but the default client capability still uses the legacy `urn:ietf:params:netconf:base:1.0#candidate` form rather than the RFC 6241 `urn:ietf:params:netconf:capability:candidate:1.0` URN. |
4040
| `<commit>` | `Supported` | Standard commit is implemented. |
41-
| `:confirmed-commit:1.1` | `Supported with caveats` | `commitConfirm(seconds, persistToken)` and `cancelCommit(persistId)` exist, but the default client capability still uses the legacy `base:1.0#confirmed-commit` form instead of the RFC 6241 capability URN. |
42-
| `:validate:1.0` | `Supported with caveats` | `validate()` is implemented against candidate, but default advertisement still uses the legacy `base:1.0#validate` URI and the call is not capability-gated at runtime. |
41+
| `:confirmed-commit:1.1` | `Supported with caveats` | `commitConfirm(seconds, persistToken)` and `cancelCommit(persistId)` are runtime-gated. Persist-based flows require modern `confirmed-commit:1.1`, while legacy confirmed-commit remains usable for same-session confirmation flows without `persist`. The default client capability advertisement still uses the legacy `base:1.0#confirmed-commit` form. |
42+
| `:validate:1.0` | `Supported with caveats` | `validate()` is implemented against candidate and now fails locally when validate support is absent, but default advertisement still uses the legacy `base:1.0#validate` URI. |
4343
| `<lock>` / `<unlock>` | `Partial` | Candidate lock/unlock helpers exist. There is no first-class running datastore lock helper. |
4444
| `:writable-running:1.0` | `Partial` | Running config retrieval exists, but there is no `edit-config` helper that targets `running` and the capability is not advertised by default. |
4545
| `:startup:1.0` | `Not implemented` | No first-class startup datastore copy/delete flows are present. |
@@ -79,7 +79,7 @@ Mental model: today the library is a synchronous, JSch-backed NETCONF-over-SSH c
7979
## Interoperability caveats worth knowing
8080

8181
- Default optional capability advertisement still uses legacy `urn:ietf:params:netconf:base:1.0#...` forms in `Device.DEFAULT_CLIENT_CAPABILITIES`. Many servers accept these, but strict RFC 6241 capability matching may not.
82-
- Capability negotiation is parsed but not enforced consistently before invoking optional operations. The caller can request candidate, validate, confirmed-commit, or NMDA operations even if the server never advertised them.
82+
- Candidate, validate, and confirmed-commit flows are now capability-gated before the RPC is sent. Other optional operations are still not enforced uniformly, especially outside the classic RFC 6241 capability set.
8383
- `NetconfSession` should be treated as a single sequential request/response channel. Use separate sessions for concurrent workflows.
8484
- The SSH transport is still tightly coupled to JSch. That preserves the current JSch-based deployment model, including existing FIPS-oriented environments, but transport abstraction is future work rather than current behavior.
8585

src/main/java/net/juniper/netconf/CommitException.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@
88

99
package net.juniper.netconf;
1010

11-
import java.io.IOException;
11+
import net.juniper.netconf.element.RpcReply;
1212

1313
/**
1414
* Describes exceptions related to commit operation
1515
*/
16-
public class CommitException extends IOException {
16+
public class CommitException extends RpcErrorException {
1717
CommitException(String msg) {
1818
super(msg);
1919
}
20+
21+
CommitException(String msg, RpcReply rpcReply) {
22+
super(msg, rpcReply);
23+
}
2024
}

src/main/java/net/juniper/netconf/Device.java

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ public class Device implements AutoCloseable {
9090

9191
private final DocumentBuilder xmlBuilder;
9292
private final List<String> netconfCapabilities;
93+
private final List<String> advertisedNetconfCapabilities;
9394
private final String helloRpc;
9495

9596
private ChannelSubsystem sshChannel;
@@ -373,7 +374,9 @@ private Device(Builder b) throws NetconfException {
373374
throw new NetconfException("Cannot create XML Parser", e);
374375
}
375376

376-
this.helloRpc = createHelloRPC(this.netconfCapabilities);
377+
Hello hello = createHello(this.netconfCapabilities);
378+
this.advertisedNetconfCapabilities = hello.getCapabilities();
379+
this.helloRpc = createHelloRPC(hello);
377380
}
378381

379382

@@ -385,7 +388,7 @@ private Device(Builder b) throws NetconfException {
385388
* @return List of default client capabilities.
386389
*/
387390
protected List<String> getDefaultClientCapabilities() {
388-
return DEFAULT_CLIENT_CAPABILITIES;
391+
return createHello(DEFAULT_CLIENT_CAPABILITIES).getCapabilities();
389392
}
390393

391394
/**
@@ -395,12 +398,14 @@ protected List<String> getDefaultClientCapabilities() {
395398
* @param capabilities A list of netconf capabilities
396399
* @return the hello RPC that represents those capabilities.
397400
*/
398-
private String createHelloRPC(List<String> capabilities) {
401+
private Hello createHello(List<String> capabilities) {
399402
return Hello.builder()
400403
.capabilities(capabilities)
401-
.build()
402-
.getXml()
403-
+ NetconfConstants.DEVICE_PROMPT;
404+
.build();
405+
}
406+
407+
private String createHelloRPC(Hello hello) {
408+
return hello.getXml() + NetconfConstants.DEVICE_PROMPT;
404409
}
405410

406411
/**
@@ -450,7 +455,8 @@ private NetconfSession createNetconfSession() throws NetconfException {
450455
channel = (ChannelSubsystem) session.openChannel("subsystem");
451456
channel.setSubsystem("netconf");
452457
NetconfSession establishedSession =
453-
new NetconfSession(channel, connectionTimeout, commandTimeout, helloRpc, xmlBuilder);
458+
new NetconfSession(channel, connectionTimeout, commandTimeout,
459+
advertisedNetconfCapabilities, helloRpc, xmlBuilder);
454460
sshSession = session;
455461
sshChannel = channel;
456462
return establishedSession;
@@ -880,6 +886,20 @@ public String getSessionId() {
880886
return this.netconfSession.getSessionId();
881887
}
882888

889+
/**
890+
* Returns the negotiated capability view for the active NETCONF session.
891+
*
892+
* @return immutable capability snapshot
893+
* @throws IllegalStateException if the connection is not established
894+
*/
895+
public NegotiatedCapabilities getNegotiatedCapabilities() {
896+
if (netconfSession == null) {
897+
throw new IllegalStateException("Cannot get negotiated capabilities, you need "
898+
+ "to establish a connection first.");
899+
}
900+
return this.netconfSession.getNegotiatedCapabilities();
901+
}
902+
883903
/**
884904
* Check if the last RPC reply returned from Netconf server has any error.
885905
*
@@ -1267,8 +1287,13 @@ public XML getRunningConfig() throws SAXException, IOException {
12671287
/**
12681288
* Validate the candidate configuration.
12691289
*
1270-
* @return true if validation successful.
1290+
* @return {@code true} if validation completed with a clean {@code <ok/>}
1291+
* reply; {@code false} for warning-only or other non-error
1292+
* non-{@code <ok/>} replies
12711293
* @throws java.io.IOException If there are errors communicating with the netconf server.
1294+
* @throws net.juniper.netconf.ValidateException if the server returns one
1295+
* or more {@code <rpc-error>}
1296+
* elements
12721297
* @throws org.xml.sax.SAXException If there are errors parsing the XML reply.
12731298
*/
12741299
public boolean validate() throws IOException, SAXException {

src/main/java/net/juniper/netconf/LoadException.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
package net.juniper.netconf;
99

10-
import java.io.IOException;
10+
import net.juniper.netconf.element.RpcReply;
1111

1212
/**
1313
* Exception thrown when a <em>load</em> RPC returns &lt;rpc-error&gt; or otherwise
@@ -20,7 +20,7 @@
2020
* <li>just the root cause.</li>
2121
* </ol>
2222
*/
23-
public class LoadException extends IOException {
23+
public class LoadException extends RpcErrorException {
2424

2525
/**
2626
* Creates a {@code LoadException} with the supplied message.
@@ -41,6 +41,16 @@ public LoadException(String message, Throwable cause) {
4141
super(message, cause);
4242
}
4343

44+
/**
45+
* Creates a {@code LoadException} with a message and parsed reply.
46+
*
47+
* @param message description of the load failure
48+
* @param rpcReply parsed NETCONF reply that triggered the failure
49+
*/
50+
public LoadException(String message, RpcReply rpcReply) {
51+
super(message, rpcReply);
52+
}
53+
4454
/**
4555
* Creates a {@code LoadException} that wraps an underlying cause.
4656
*

0 commit comments

Comments
 (0)