← Back to list

Device API-First: Automating with RESTCONF/NETCONF

Model the network, not the CLI — practical patterns you can ship tomorrow.

Hmbali · 2025-11-13 03:46 · 16 claps · 10.4 min read paywalled
#network-automation #netconf #python #json #xml
Open on Medium ↗

Device API-First: Automating with RESTCONF/NETCONF

Model the network, not the CLI — practical patterns you can ship tomorrow.

Photo by Igor Omilaev on Unsplash

Photo by Igor Omilaev on Unsplash

If you’ve ever wrote a brittle “send these five commands” script and held your breath while pasting into 40 switches, this article is your soft landing. Instead of screen-scraping CLIs, we’ll treat devices as APIs and push changes in a way that’s structured, testable, and reversible. Two workhorses make this possible:

  • NETCONF: an RPC protocol over SSH that manipulates YANG-modeled configuration and state (XML payloads, transactional features like candidate, validate, commit confirmed).
  • RESTCONF: a RESTful interface that exposes the same YANG data via HTTP verbs (GET/POST/PUT/PATCH/DELETE) with JSON or XML bodies.

We’ll keep the tone conversational and the examples real: copy-pasteable curl, small Python programs (requests and ncclient), and deployment patterns that scale from a home lab to a multi-vendor fabric. By the end, you’ll be able to:

  • Read and write device configuration by model (IETF or OpenConfig), not by CLI.
  • Use idempotent operations with dry-run style diffs/validations.
  • Safely commit, rollback, and validate your intent — without praying to the copy-paste gods.

Device API-First, in one sentence

Model first, transport second: decide what data model (YANG) you want to manipulate (e.g., ietf-interfaces, openconfig-bgp), then choose how to transport it (NETCONF for RPC/transactions, RESTCONF for HTTP simplicity). When you anchor on the model, your code reads like “set interface X to IP Y” rather than “send these 7 CLI lines.”

The mental model: YANG → (NETCONF | RESTCONF)

  • YANG is the schema language describing your device’s data: containers, lists, leafs, types, constraints. Think “tables and columns,” but for network state and config.
  • NETCONF wraps YANG in an RPC protocol over SSH (port 830 on most vendors). You send XML; you get XML; you can lock, validate, edit-config, commit, and rollback.
  • RESTCONF maps YANG to HTTP paths and methods (usually /restconf/data/...). You send JSON or XML with standard verbs. It’s friendlier to web tooling and gateways.

Same data, different delivery drivers.

What we’ll use (tooling)

  • Python 3.10+
  • requests for RESTCONF
  • ncclient for NETCONF
  • rich for nice prints (optional)

requirements.txt:

requests
ncclient
rich

Create a venv and install:

python -m venv env
source env/bin/activate  # Windows: env\Scripts\activate
pip install -r requirements.txt

⚠️ Production note: pin versions and use a proper secrets manager. Lab creds in env vars are fine for learning.

Enabling APIs on devices (quick notes)

Vendors differ, but the idea is consistent:

  • NETCONF: enable the NETCONF server over SSH (often netconf ssh or equivalent). Port 830 by default.
  • RESTCONF: enable the RESTCONF service (HTTPS). Many platforms serve it under /restconf.
  • Auth: use accounts with least privilege; prefer SSH keys for NETCONF and certificates for RESTCONF where possible.

If you’re using IOS-XE, Arista EOS, or Junos, these are typically one-liners in global config or system configuration. Check your platform guide for exact commands and ACL/TLS details.

How RESTCONF paths work (no mystery, just mapping)

Map YANG to paths like this:

/restconf/data/<module>:<container>/<list>=<key>/<leaf>

For example, IETF interfaces:

/restconf/data/ietf-interfaces:interfaces/interface=Loopback123

JSON body uses the fully qualified top-level container name:

{
  "ietf-interfaces:interface": {
    "name": "Loopback123",
    "type": "iana-if-type:softwareLoopback",
    "enabled": true,
    "ietf-ip:ipv4": {
      "address": [
        { "ip": "192.0.2.10", "netmask": "255.255.255.255" }
      ]
    }
  }
}

HTTP rules of thumb:

  • GET: read data (Accept: application/yang-data+json)
  • PUT: create/replace a specific resource (idempotent)
  • PATCH: partial update (merge semantics)
  • POST: create under a collection (server chooses key)
  • DELETE: remove

Quick RESTCONF practice: create a loopback (cURL + Python)

cURL

DEVICE=10.0.0.11
USER=netops
PASS='labpass'
BASE="https://${DEVICE}/restconf/data"
HDR_ACCEPT="Accept: application/yang-data+json"
HDR_CT="Content-Type: application/yang-data+json"
# Create/replace Loopback123
curl -sk -u "$USER:$PASS" -H "$HDR_ACCEPT" -H "$HDR_CT" \
  -X PUT \
  "$BASE/ietf-interfaces:interfaces/interface=Loopback123" \
  -d '{
        "ietf-interfaces:interface": {
          "name": "Loopback123",
          "type": "iana-if-type:softwareLoopback",
          "enabled": true,
          "ietf-ip:ipv4": {
            "address": [
              {"ip":"192.0.2.10", "netmask":"255.255.255.255"}
            ]
          }
        }
      }'

Verify:

curl -sk -u "$USER:$PASS" -H "$HDR_ACCEPT" \
  "$BASE/ietf-interfaces:interfaces/interface=Loopback123"

Delete:

curl -sk -u "$USER:$PASS" -X DELETE \
  "$BASE/ietf-interfaces:interfaces/interface=Loopback123"

Python (requests)

# restconf_loopback.py
import os, requests, json
from rich import print
requests.packages.urllib3.disable_warnings()
DEVICE = os.getenv("DEVICE", "10.0.0.11")
USER   = os.getenv("USER",   "netops")
PASS   = os.getenv("PASS",   "labpass")
BASE = f"https://{DEVICE}/restconf/data"
HDRS = {
    "Accept": "application/yang-data+json",
    "Content-Type": "application/yang-data+json"
}
payload = {
    "ietf-interfaces:interface": {
        "name": "Loopback123",
        "type": "iana-if-type:softwareLoopback",
        "enabled": True,
        "ietf-ip:ipv4": {
            "address": [{"ip": "192.0.2.10", "netmask": "255.255.255.255"}]
        }
    }
}
with requests.Session() as s:
    s.auth = (USER, PASS)
    s.verify = False  # lab only; use proper TLS in prod
    # PUT is idempotent here
    r = s.put(f"{BASE}/ietf-interfaces:interfaces/interface=Loopback123",
              headers=HDRS, data=json.dumps(payload))
    print("[bold]PUT status[/bold]:", r.status_code, r.text)
    r = s.get(f"{BASE}/ietf-interfaces:interfaces/interface=Loopback123",
              headers={"Accept": "application/yang-data+json"})
    print("[bold]GET[/bold]:", r.status_code, r.json())

Idempotency check: run it twice — the second PUT should return success without changing anything (201/204 depending on the platform).

Quick NETCONF practice: create the same loopback (ncclient)

# netconf_loopback.py
from ncclient import manager
from rich import print
DEVICE = "10.0.0.11"
USER   = "netops"
PASS   = "labpass"
config = """
<config>
  <interfaces xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces">
    <interface>
      <name>Loopback123</name>
      <type xmlns:ianaift="urn:ietf:params:xml:ns:yang:iana-if-type">
        ianaift:softwareLoopback
      </type>
      <enabled>true</enabled>
      <ipv4 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip">
        <address>
          <ip>192.0.2.10</ip>
          <netmask>255.255.255.255</netmask>
        </address>
      </ipv4>
    </interface>
  </interfaces>
</config>
"""
with manager.connect(host=DEVICE, port=830, username=USER, password=PASS,
                     hostkey_verify=False, allow_agent=False,
                     look_for_keys=False, timeout=30) as m:
    # Use candidate if supported; else target='running'
    try:
        print("[bold cyan]Lock candidate[/bold cyan]")
        m.lock('candidate')
        print("[bold cyan]Edit config[/bold cyan]")
        m.edit_config(target='candidate', config=config)
        print("[bold cyan]Validate[/bold cyan]")
        m.validate()
        print("[bold cyan]Commit[/bold cyan]")
        m.commit()
    except Exception as e:
        print("[red]Error, attempting discard[/red]", e)
        try:
            m.discard_changes()
        except Exception:
            pass
    finally:
        try:
            m.unlock('candidate')
        except Exception:
            pass
    # Read back with subtree filter
    flt = """
    <filter>
      <interfaces xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces">
        <interface>
          <name>Loopback123</name>
        </interface>
      </interfaces>
    </filter>
    """
    reply = m.get_config(source='running', filter=flt)
    print("[bold]GET-CONFIG reply[/bold]:")
    print(reply.xml)

Why NETCONF here? You get transactions: lock → edit → validate → commit. If validation fails, no partial config leaks into running.

Commit confirmed (safer yet):

# Ask device to auto-rollback unless we confirm within N minutes
m.commit(confirmed=True, confirm_timeout=3)  # minutes
# ... run post-checks ...
m.commit()  # confirm the change

Reading state, not just config (both protocols)

  • RESTCONF: /restconf/data/ietf-interfaces:interfaces-state (or /operational on some platforms).
  • NETCONF: get (operational) vs get-config (config). Use subtree or XPath filters to keep responses small.

Example NETCONF operational read:

flt = """
<filter>
  <interfaces-state xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces"/>
</filter>
"""
reply = m.get(filter=flt)

OpenConfig vs. IETF vs. vendor models

  • IETF modules (ietf-interfaces, ietf-ip, ietf-routing) are widely supported and great for generic tasks.
  • OpenConfig is community-driven, with rich models (e.g., openconfig-bgp). Great for multi-vendor strategies.
  • Vendor namespaces cover platform-specific features not standardized yet.

Pick one policy: prefer generic (IETF/OpenConfig) unless you truly need vendor knobs. You can always mix: model-agnostic for 90%, vendor models for the last mile.

Practical pattern #1 — Idempotent RESTCONF Interface Template

We’ll capture intent in JSON (host-agnostic), render keys, and issue a PUT so you can run it repeatedly without drift.

# restconf_iface_template.py
import os, json, requests
from jinja2 import Template
requests.packages.urllib3.disable_warnings()
DEVICE = os.getenv("DEVICE", "10.0.0.11")
USER   = os.getenv("USER", "netops")
PASS   = os.getenv("PASS", "labpass")
T = Template("""
{
  "ietf-interfaces:interface": {
    "name": "{{ name }}",
    "type": "iana-if-type:softwareLoopback",
    "enabled": true,
    "ietf-ip:ipv4": {
      "address": [{ "ip": "{{ ip }}", "netmask": "{{ netmask }}" }]
    }
  }
}
""")
def upsert_loopback(name, ip, netmask):
    base = f"https://{DEVICE}/restconf/data"
    hdrs = {"Accept":"application/yang-data+json","Content-Type":"application/yang-data+json"}
    body = T.render(name=name, ip=ip, netmask=netmask)
    with requests.Session() as s:
        s.auth = (USER, PASS); s.verify = False
        r = s.put(f"{base}/ietf-interfaces:interfaces/interface={name}",
                  headers=hdrs, data=body)
        return r.status_code, r.text
print(upsert_loopback("Loopback200", "198.51.100.10", "255.255.255.255"))

Run it twice; the second time is a no-op because PUT replaces the resource with the same content.

Practical pattern #2 — Transactional NETCONF with pre/post checks

We’ll guard changes with validations and a rollback path.

# netconf_guarded_change.py
from ncclient import manager
from rich import print
DEVICE="10.0.0.11"; USER="netops"; PASS="labpass"
def bgp_neighbor_up(m, nbr):
    # Replace with OpenConfig or IETF routing model checks as supported
    try:
        flt = """
        <filter xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0">
          <bgp-state xmlns="http://openconfig.net/yang/bgp"/>
        </filter>
        """
        # Some vendors don't implement this; handle gracefully
        r = m.get(filter=flt)
        return nbr in r.xml and "<state><session-state>ESTABLISHED</session-state>" in r.xml
    except Exception:
        return True  # Don't block if model unsupported in lab
config = """... an XML <config> block to add a BGP neighbor ..."""
with manager.connect(host=DEVICE, port=830, username=USER, password=PASS,
                     hostkey_verify=False, allow_agent=False, look_for_keys=False) as m:
    m.lock('candidate')
    try:
        if not bgp_neighbor_up(m, "203.0.113.1"):
            print("[yellow]Precheck: neighbor not up (expected). Proceeding.[/yellow]")
        m.edit_config(target='candidate', config=config)
        m.validate()
        # Safer: commit confirmed, then confirm after post-check
        m.commit(confirmed=True, confirm_timeout=5)
        ok = bgp_neighbor_up(m, "203.0.113.1")
        if not ok:
            print("[red]Post-check failed-auto rollback will occur[/red]")
        else:
            m.commit()  # confirm
            print("[green]Committed[/green]")
    finally:
        try: m.unlock('candidate')
        except Exception: pass

This pattern (precheck → edit → validate → commit-confirmed → postcheck → confirm) is your “transaction with safety net.”

Practical pattern #3 — Multi-device concurrency (RESTCONF)

HTTP is I/O-bound; threads work well. Throttle to avoid hammering AAA and CPUs.

# restconf_fleet.py
import os, json, requests
from concurrent.futures import ThreadPoolExecutor, as_completed
requests.packages.urllib3.disable_warnings()
HDRS = {"Accept":"application/yang-data+json","Content-Type":"application/yang-data+json"}
INV = [
    {"host":"10.0.0.11","user":"netops","pass":"labpass"},
    {"host":"10.0.0.12","user":"netops","pass":"labpass"},
    # add more...
]
def put_loopback(dev, name, ip):
    base = f"https://{dev['host']}/restconf/data"
    payload = {
      "ietf-interfaces:interface": {
        "name": name, "type":"iana-if-type:softwareLoopback", "enabled": True,
        "ietf-ip:ipv4":{"address":[{"ip": ip, "netmask":"255.255.255.255"}]}
      }
    }
    with requests.Session() as s:
        s.auth = (dev["user"], dev["pass"]); s.verify = False
        r = s.put(f"{base}/ietf-interfaces:interfaces/interface={name}",
                  headers=HDRS, data=json.dumps(payload), timeout=20)
        return dev["host"], r.status_code
def main():
    futures=[]
    with ThreadPoolExecutor(max_workers=10) as pool:
        for d in INV:
            futures.append(pool.submit(put_loopback, d, "Loopback99", "203.0.113.99"))
        for f in as_completed(futures):
            host, status = f.result()
            print(host, status)
if __name__ == "__main__":
    main()

Pro tips:

  • Add small random jitter to starts.
  • Centralize retries with backoff (HTTP 5xx, 429).
  • Consider ETag/If-Match for optimistic concurrency if your platform supports it.

Practical pattern #4 — OpenConfig BGP via RESTCONF (JSON)

This shows the flavor; adapt paths to your platform’s OpenConfig support.

Path:

/restconf/data/openconfig-bgp:bgp/neighbors/neighbor=203.0.113.1/config

Payload:

{
  "openconfig-bgp:config": {
    "neighbor-address": "203.0.113.1",
    "peer-as": 65002,
    "description": "Edge peer"
  }
}

Python:

# restconf_bgp_neighbor.py
import json, requests
requests.packages.urllib3.disable_warnings()
BASE="https://10.0.0.11/restconf/data"
HDRS={"Accept":"application/yang-data+json","Content-Type":"application/yang-data+json"}
with requests.Session() as s:
    s.auth=("netops","labpass"); s.verify=False
    r = s.put(f"{BASE}/openconfig-bgp:bgp/neighbors/neighbor=203.0.113.1/config",
              headers=HDRS,
              data=json.dumps({"openconfig-bgp:config":{
                    "neighbor-address":"203.0.113.1",
                    "peer-as":65002,
                    "description":"Edge peer"}}))
    print(r.status_code, r.text)

If your device wants you to create the parent collection first, POST the neighbor object under /neighbors instead of PUTting the specific neighbor path—behavior varies.

Practical pattern #5 — NETCONF filters that scale

Avoid pulling the entire config. Use subtree filters so responses are small and fast.

flt = """
<filter>
  <routing xmlns="urn:ietf:params:xml:ns:yang:ietf-routing">
    <routing-instance>
      <name>default</name>
      <routing-protocols/>
    </routing-instance>
  </routing>
</filter>
"""
reply = m.get_config(source='running', filter=flt)

XPath filters are also supported on many platforms:

reply = m.get(xpath="/interfaces/interface[name='Loopback123']")

Error handling & observability

RESTCONF:

  • HTTP 200/201/204 → OK,
  • 400/409/412/415 → model or method issues (bad schema, conflict, precondition failed, unsupported media type),
  • 401/403 → auth/authorization,
  • 404 → path not found (model not present or wrong namespace),
  • 5xx → device unhappy.

Parse JSON error bodies if provided; vendors often include helpful error-tag/error-info tied to the underlying YANG validation.

NETCONF:

  • Exceptions expose <rpc-error> with fields: error-type, error-tag, error-severity, error-info.
  • Wrap steps and always be able to discard-changes or rely on commit confirmed to auto-rollback.

Logging:

  • Log path, payload, response code/body. Redact secrets.
  • Keep per-device request IDs (correlate across retries).
  • Archive pre/post snapshots for CAB/troubleshooting.

Security basics you’ll thank yourself for

  • SSH keys for NETCONF; TLS with real certs for RESTCONF.
  • NACM / RBAC: don’t give the automation account global write; scope it to the models/paths it needs.
  • Secrets: env vars for labs; Vault/Secrets Manager in prod. Rotate!
  • Network ACLs: restrict API ports (830 for NETCONF, 443 for RESTCONF) to your automation workers.

Performance tips

  • Use HTTP keep-alive via requests.Session.
  • Prefer PUT idempotent upserts over POST when the key is known.
  • Batch work with a ThreadPoolExecutor; tune concurrency to your AAA/backend limits.
  • For info-heavy tasks, prefer NETCONF filters or RESTCONF selective paths rather than reading everything.

Designing your “API-first” codebase (small but sane)

A minimal shape:

api-first/
├─ inventory/devices.yaml
├─ lib/
│  ├─ restconf.py    # thin wrapper (session, headers, get/put/patch)
│  ├─ netconf.py     # thin wrapper (connect, edit, commit, confirmed)
│  ├─ models/        # paths & payload builders per YANG model
│  └─ checks.py      # pre/post validators
├─ tasks/
│  ├─ upsert_loopback.py
│  ├─ add_bgp_neighbor.py
│  └─ verify_ntp.py
└─ main.py           # argparse CLI

Keep models and payload builders separate from transports so you can flip between RESTCONF and NETCONF without rewriting your logic.

Choosing RESTCONF vs. NETCONF (cheat-sheet)

Use case Pick Why Quick reads/writes, easy tooling, JSON preference RESTCONF HTTP, requests, curl—great ergonomics Transactions with lock/validate/rollback NETCONF candidate, validate, commit confirmed Bulk concurrent reads from many devices RESTCONF Lightweight, easy to fan out Precise, schema-validated edits with guaranteed semantics NETCONF RPCs map cleanly to config transactions Streaming telemetry / subscriptions (model-driven) NETCONF (YANG-Push) Standardized subscriptions/notifications Gateways, proxies, auth delegation RESTCONF Plays well with API gateways & OAuth fronts

In practice you’ll mix them: RESTCONF for fleet reads and simple idempotent writes, NETCONF for the scary changes that demand transactions.

Common “gotchas” and how to dodge them

  • Wrong namespace in RESTCONF → 404s. Use fully qualified module names (ietf-interfaces:interfaces).
  • JSON vs XML mismatch → 415 Unsupported Media Type. Check Content-Type and Accept.
  • Device lacks model → 404 or RPC error. Fall back to vendor model or adjust feature scope.
  • Partial config drift → favor PUT (replace) over PATCH (merge) when possible; or combine PATCH with post-read validation to ensure your intent landed.
  • Over-concurrency → AAA lockouts. Start with 5–10 threads; monitor and tune.
  • Skipping validation → run something after every change (even a GET to confirm the leaf you set).

A complete mini-workflow (copy this for your next CAB)

1. Inventory: load devices and their capabilities (RESTCONF/NETCONF).

2. Precheck: read current state; ensure you’re not about to break the world (neighbors up, CPU sane, etc.).

3. Change:

  • If reversible/critical → NETCONF candidate + validate + commit confirmed
  • If simple/idempotent → RESTCONF PUT with a full resource body.

4. Postcheck: re-read the relevant YANG paths; compare to intent.

5. Confirm or rollback: confirm the commit or let it auto-rollback; for RESTCONF, perform a compensating PUT/DELETE if postcheck fails.

6. Archive: store requests, responses, and a compliance digest.

This is the “render → dry-run (validate) → commit → verify → archive” loop — API-first style.

Appendix: a few more handy snippets

RESTCONF list creation with POST (server picks the key)

Some collections use POST to create a new child if the key is generated on the device.

curl -sk -u "$USER:$PASS" -H "$HDR_ACCEPT" -H "$HDR_CT" \
  -X POST \
  "$BASE/example-mod:things" \
  -d '{ "example-mod:thing": { "config": { "description": "auto-created" } } }'

RESTCONF partial update with PATCH (merge)

curl -sk -u "$USER:$PASS" -H "$HDR_ACCEPT" -H "$HDR_CT" \
  -X PATCH \
  "$BASE/ietf-interfaces:interfaces/interface=Loopback123" \
  -d '{ "ietf-interfaces:interface": { "enabled": false } }'

NETCONF “replace” edit

<config>
  <interfaces xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces" nc:operation="replace"
              xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0">
    <interface>
      <name>Loopback123</name>
      <enabled>false</enabled>
    </interface>
  </interfaces>
</config>

NETCONF notifications / YANG-Push (sketch)

# Many vendors require a specific namespace & RPC; adjust for your platform
sub = """
<establish-subscription xmlns="urn:ietf:params:xml:ns:yang:ietf-subscribed-notifications">
  <stream>yang-push</stream>
  <filter type="subtree">
    <interfaces-state xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces"/>
  </filter>
  <period>5000</period>  <!-- 5s -->
</establish-subscription>
"""
m.dispatch(to_ele(sub))
for n in m.take_notifications(timeout=60):
    print(n.notification_xml)

Wrapping it up

“Device API-first” isn’t hype; it’s a calmer way to operate networks. When you express intent with YANG models and use protocols built for configuration, the blast radius shrinks and your work becomes predictable:

  • RESTCONF gives you ergonomic, idempotent HTTP calls — perfect for fast reads and simple writes at scale.
  • NETCONF gives you transactions — lock, validate, commit confirmed — which make scary changes boring.

Most teams end up mixing both: RESTCONF for fleet-wide inventory and small upserts, NETCONF for anything that deserves a dry-run and a safety net. Along the way, lean on IETF and OpenConfig models to stay vendor-neutral as much as possible, and dip into vendor namespaces only when you must.

If you carry one habit into your next maintenance window, make it this loop: render intent → validate/dry-run → commit (confirmed) → post-check → archive (and auto-rollback if needed). That’s API-first — and it scales with your ambition.

TL;DR index of examples

  • restconf_loopback.py: idempotent interface upsert over RESTCONF
  • netconf_loopback.py: transactional interface creation with candidate/commit
  • restconf_fleet.py: concurrent RESTCONF across devices
  • restconf_bgp_neighbor.py: OpenConfig BGP neighbor via JSON
  • netconf_guarded_change.py: commit confirmed with pre/post checks

메타데이터
post_id
efcd4e5dfb84
slug
device-api-first-automating-with-restconf-netconf-efcd4e5dfb84
url
https://medium.com/@hmbali96/device-api-first-automating-with-restconf-netconf-efcd4e5dfb84
canonical_url
https://medium.com/@hmbali96/device-api-first-automating-with-restconf-netconf-efcd4e5dfb84
author_url
https://medium.com/@hmbali96
status
ok
fetched_at
2026-07-26 17:34:23