Add DHCP server container. 39/14139/1
authorAlex Stancu <alexandru.stancu@highstreet-technologies.com>
Tue, 4 Feb 2025 10:22:34 +0000 (12:22 +0200)
committerAlex Stancu <alexandru.stancu@highstreet-technologies.com>
Tue, 4 Feb 2025 10:22:52 +0000 (12:22 +0200)
Issue-ID: OAM-427
Change-Id: I89a189256ae90303dfd381b32f91bc9ed014e3a0
Signed-off-by: Alex Stancu <alexandru.stancu@highstreet-technologies.com>
solution/README.md
solution/infra/.env [new file with mode: 0644]
solution/infra/dhcp-server/kea-dhcp4.conf [new file with mode: 0644]
solution/infra/dhcp-tester/Dockerfile [new file with mode: 0644]
solution/infra/dhcp-tester/docker-entrypoint.sh [new file with mode: 0755]
solution/infra/dhcp-tester/test_dhcp.py [new file with mode: 0644]
solution/infra/docker-compose.yaml [new file with mode: 0644]
solution/network/.env
solution/smo/oam/docker-compose.yaml

index b24ab0f..584c830 100644 (file)
@@ -151,7 +151,8 @@ next chapters.
 
 ```bash
 source .oam/bin/activate
-docker compose -f smo/common/docker-compose.yaml up -d --wait
+docker compose -f infra/docker-compose.yaml up -d
+docker compose -f smo/common/docker-compose.yaml up -d
 
 # optionally adjust the users.csv file to create new users
 vim users.csv
@@ -164,7 +165,21 @@ docker compose -f smo/oam/docker-compose.yaml up -d
 docker compose -f smo/apps/docker-compose.yaml up -d
 
 # the cpu load is low again, we can start a simulated network
+```
+
+#### Simulated network
+Before starting the simulated network, you need to locally build the docker images.
+This is because of copyright issues with the 3GPP YANG models.
 
+The build should be pretty straightforward. The repository containing the PyNTS code needs to be cloned and then a command needs to be ran for building the images. Run this from another terminal, in another folder, not in this repo.
+
+```bash
+git clone "https://gerrit.o-ran-sc.org/r/sim/o1-ofhmp-interfaces"
+cd o1-ofhmp-interfaces
+make build-all
+```
+After everything is built successfully, you can return to your solution folder here and start the network.
+```bash
 docker compose -f network/docker-compose.yaml up -d
 docker compose -f network/docker-compose.yaml restart pynts-o-du-o1
 ```
@@ -212,20 +227,16 @@ docker compose -f network/docker-compose.yaml up -d
 Usually the first ves:event gets lost. Please restart the O-DU docker container(s) to send a second ves:pnfRegistration.
 
 ```
-docker compose -f network/docker-compose.yaml restart ntsim-ng-o-du-1122
-python network/config.py
+docker compose -f network/docker-compose.yaml restart pynts-o-du-o1
 ```
 
-The python script configures the simulated O-DU and O-RU according to O-RAN hybrid architecture.
+The simulated O-DU and O-RUs are pre-configured according to O-RAN hybrid architecture.
 
 O-RU - NETCONF Call HOME and NETCONF notifications
 O-DU - ves:pnfRegistration and ves:fault, ves:heartbeat
 
 ![ves:pnfRegistration in ODLUX](docs/nstim-ng-connected-after-ves-pnf-registration-in-odlux.png "ves:pnfRegistration in ODLUX")
 
-'True' indicated that the settings through SDN-R to the NETCONF server were
-successful.
-
 SDN-R reads the fault events from DMaaP and processes them.
 Finally the fault events are visible in ODLUX.
 
diff --git a/solution/infra/.env b/solution/infra/.env
new file mode 100644 (file)
index 0000000..a5a7d2e
--- /dev/null
@@ -0,0 +1,19 @@
+################################################################################
+# Copyright 2025 highstreet technologies
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+NETWORK_SUBNET_DHCP_IPv4=172.99.0.0/16
+NETWORK_GATEWAY_DHCP_IPv4=172.99.0.1
+NETWORK_DHCP_CONTAINER_IPv4=172.99.0.10
\ No newline at end of file
diff --git a/solution/infra/dhcp-server/kea-dhcp4.conf b/solution/infra/dhcp-server/kea-dhcp4.conf
new file mode 100644 (file)
index 0000000..82cf949
--- /dev/null
@@ -0,0 +1,91 @@
+{
+  "Dhcp4": {
+    "interfaces-config": {
+      "interfaces": [ "*" ]
+    },
+    # 1. Declare a client-class that triggers if Option[60] == "o-ran-ru2/PyNTS"
+    "client-classes": [
+      {
+        "name": "pynts-class",
+        "test": "substring(option[60].text,0,15) == 'o-ran-ru2/pynts'",
+        # "test": "substring(option[60].hex, 0, 30) == '6F2D72616E2D7275322F50794E5453'"                
+      }
+    ],
+
+    "lease-database": {
+      "type": "memfile"
+    },
+    "subnet4": [
+          {
+            "subnet": "172.99.0.0/16",
+            "id": 1,
+            "pools": [
+              {
+                "pool": "172.99.3.100 - 172.99.3.200"
+              }
+            ],    
+            "evaluate-additional-classes": [ "pynts-class" ],        
+            "option-data": [
+              {
+                "name": "routers",
+                "data": "172.99.0.1"
+              },
+              {
+                "name": "domain-name-servers",
+                "data": "8.8.8.8, 8.8.4.4"
+              }
+            ]
+          }
+      ],    
+      "option-data": [
+          {
+            "name": "vendor-encapsulated-options", #// This is Option 43
+            "code": 43,                           #// Redundant if 'name' is recognized, but often kept for clarity
+            "csv-format": false,                  #// Because we’re providing hex directly
+            "data": "81:04:AC:63:00:05:82:1F:63:6F:6E:74:72:6F:6C:6C:65:72:2E:64:63:6E:2E:73:6D:6F:2E:6F:2D:72:61:6E:2D:73:63:2E:6F:72:67:86:01:01:83:04:AC:63:00:06:84:22:76:65:73:2D:63:6F:6C:6C:65:63:74:6F:72:2E:64:63:6E:2E:73:6D:6F:2E:6F:2D:72:61:6E:2D:73:63:2E:6F:72:67:85:01:00",
+            "always-send": true
+            # TLVs Explanation:
+            # 81:04:AC:63:00:05
+            # - Type: 0x81 (Controller IP)
+            # - Length: 0x04 (4 bytes)
+            # - Value: AC:63:00:05 (172.99.0.5)
+
+            # 82:1F:63:6F:6E:74:72:6F:6C:6C:65:72:2E:64:63:6E:2E:73:6D:6F:2E:6F:2D:72:61:6E:2D:73:63:2E:6F:72:67
+            # - Type: 0x82 (Controller FQDN)
+            # - Length: 0x1F (31 bytes)
+            # - Value: controller.dcn.smo.o-ran-sc.org (ASCII encoded)
+
+            # 86:01:01
+            # - Type: 0x86 (Call Home Type)
+            # - Length: 0x01 (1 byte)
+            # - Value: 0x01 (Call Home over TLS)
+
+            # 83:04:AC:63:00:06
+            # - Type: 0x83 (Event Collector IP)
+            # - Length: 0x04 (4 bytes)
+            # - Value: AC:63:00:06 (172.99.0.6)
+
+            # 84:22:76:65:73:2D:63:6F:6C:6C:65:63:74:6F:72:2E:64:63:6E:2E:73:6D:6F:2E:6F:2D:72:61:6E:2D:73:63:2E:6F:72:67
+            # - Type: 0x84 (Event Collector FQDN)
+            # - Length: 0x22 (34 bytes)
+            # - Value: ves-collector.dcn.smo.o-ran-sc.org (ASCII encoded)
+
+            # 85:01:00
+            # - Type: 0x85 (Event-Collector Notification Format)
+            # - Length: 0x01 (1 byte)
+            # - Value: 0x00 (pnfRegistration in ONAP defined format)
+          }
+        ],        
+    "loggers": [
+      {
+        "name": "kea-dhcp4",
+        "severity": "DEBUG",
+        "output_options": [
+          {
+            "output": "stdout"
+          }
+        ]
+      }
+    ]
+  }
+}
diff --git a/solution/infra/dhcp-tester/Dockerfile b/solution/infra/dhcp-tester/Dockerfile
new file mode 100644 (file)
index 0000000..a865fd5
--- /dev/null
@@ -0,0 +1,40 @@
+################################################################################
+# Copyright 2025 highstreet technologies
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# docker-tester/Dockerfile
+FROM python:3.9-slim
+
+# 1. Install libpcap for Scapy sniffing, and tcpdump for debugging
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    libpcap-dev tcpdump iproute2 \
+ && rm -rf /var/lib/apt/lists/*
+
+# 2. Install Scapy
+RUN pip install scapy
+
+WORKDIR /app
+
+# 3. Copy your test script
+COPY test_dhcp.py .
+
+# 4. Copy a small entrypoint script
+COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
+RUN chmod +x /usr/local/bin/docker-entrypoint.sh
+
+# Make the entrypoint script run by default
+ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+
+# We won't specify CMD here, so it can remain empty or be overridden
diff --git a/solution/infra/dhcp-tester/docker-entrypoint.sh b/solution/infra/dhcp-tester/docker-entrypoint.sh
new file mode 100755 (executable)
index 0000000..3bb799c
--- /dev/null
@@ -0,0 +1,29 @@
+#!/bin/sh
+#
+################################################################################
+# Copyright 2025 highstreet technologies
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# This script runs test_dhcp.py once, prints the result, then keeps
+# the container alive (by tailing /dev/null). That way you can
+# debug inside the container if needed.
+
+echo "[+] Starting DHCP test..."
+python3 /app/test_dhcp.py
+
+echo "[+] Test script finished. Container will remain running for debugging..."
+echo "    You can run 'docker exec -it dhcp-tester bash' (or sh) to explore."
+
+# Keep the container alive indefinitely
+tail -f /dev/null
diff --git a/solution/infra/dhcp-tester/test_dhcp.py b/solution/infra/dhcp-tester/test_dhcp.py
new file mode 100644 (file)
index 0000000..4dcd2bb
--- /dev/null
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+
+################################################################################
+# Copyright 2025 highstreet technologies
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""
+Scapy-based DHCP test script with Option 43 parsing.
+
+Sends DHCPDISCOVER, waits for DHCPOFFER, extracts vendor-specific Option 43 data,
+and prints all relevant information.
+"""
+
+from scapy.all import Ether, IP, UDP, BOOTP, DHCP, sendp, sniff, AsyncSniffer
+import time
+import sys
+
+# Mapping of Option 43 sub-option types
+OPTION_43_TYPES = {
+    0x81: "Controller IP Address",
+    0x82: "Controller FQDN",
+    0x83: "Event Collector IP Address",
+    0x84: "Event Collector FQDN",
+    0x85: "PNF Registration Format",
+    0x86: "NETCONF Call Home"
+}
+
+def parse_option_43(raw_bytes):
+    """Parses vendor-encapsulated Option 43 data."""
+    index = 0
+    parsed_data = {}
+
+    while index < len(raw_bytes):
+        if index + 2 > len(raw_bytes):
+            print("[!] Malformed Option 43 data (truncated)")
+            break
+
+        opt_type = raw_bytes[index]  # First byte is the sub-option type
+        opt_len = raw_bytes[index + 1]  # Second byte is the length
+
+        if index + 2 + opt_len > len(raw_bytes):
+            print(f"[!] Skipping invalid Option 43 sub-option {opt_type:#04x} (bad length)")
+            break
+
+        opt_value = raw_bytes[index + 2: index + 2 + opt_len]  # Value field
+
+        if opt_type in [0x81, 0x83]:  # IP Address (4 bytes)
+            if opt_len == 4:
+                parsed_value = ".".join(str(b) for b in opt_value)
+            else:
+                parsed_value = f"Invalid IP length ({opt_len})"
+        
+        elif opt_type in [0x82, 0x84]:  # FQDN (ASCII string)
+            try:
+                parsed_value = opt_value.decode("ascii")
+            except UnicodeDecodeError:
+                parsed_value = opt_value.hex()  # Fallback to hex if not ASCII
+
+        elif opt_type in [0x85, 0x86]:  # Single-byte flags
+            parsed_value = int(opt_value[0]) if opt_len == 1 else f"Invalid flag length ({opt_len})"
+        
+        else:
+            parsed_value = opt_value.hex()  # Unknown types are printed in hex
+
+        parsed_data[OPTION_43_TYPES.get(opt_type, f"Unknown Type {opt_type:#04x}")] = parsed_value
+
+        index += 2 + opt_len  # Move to the next sub-option
+
+    return parsed_data
+
+def print_dhcp_options(dhcp_options):
+    """Print all DHCP options from a list of (option, value) tuples."""
+    for opt in dhcp_options:
+        if isinstance(opt, tuple):
+            if opt[0] == "vendor_specific":  # Fix: Use the correct Scapy name for Option 43
+                print("    Option 43 (Vendor-Specific Information):")
+                parsed_43 = parse_option_43(opt[1])  # Convert raw bytes
+                for k, v in parsed_43.items():
+                    print(f"        {k}: {v}")
+            else:
+                print(f"    Option {opt[0]}: {opt[1]}")
+
+def handle_packet(pkt):
+    """Callback to process incoming DHCP packets and detect DHCPOFFER."""
+    if DHCP in pkt:
+        dhcp_opts = pkt[DHCP].options
+        for opt in dhcp_opts:
+            if isinstance(opt, tuple) and opt[0] == "message-type":
+                if opt[1] in [2, "offer"]:  # DHCPOFFER detected
+                    server_ip = pkt[IP].src
+                    offered_ip = pkt[BOOTP].yiaddr
+                    print(f"[+] Received DHCPOFFER from {server_ip}")
+                    print(f"    Offered IP: {offered_ip}")
+                    print("    Full DHCP options:")
+                    print_dhcp_options(dhcp_opts)
+                    return True
+    return False
+
+def test_dhcp():
+    # Start sniffing in *async* mode so we don't block.
+    sniff_thread = AsyncSniffer(
+        iface="eth0",
+        filter="udp and (port 67 or port 68)",
+        prn=handle_packet
+    )
+    sniff_thread.start()
+
+    time.sleep(1)  # Give it a moment to get ready
+
+    # Now send DHCPDISCOVER
+    discover = (
+        Ether(src="02:50:02:99:00:01", dst="ff:ff:ff:ff:ff:ff")
+        / IP(src="0.0.0.0", dst="255.255.255.255")
+        / UDP(sport=68, dport=67)
+        / BOOTP(chaddr=b'\x02\x50\x02\x99\x00\x01', xid=0x99999999, flags=0x8000)
+        / DHCP(options=[
+            ("message-type", "discover"),
+            ("parameter-request-list", [43]),  # Explicitly request Option 43
+            ("vendor_class_id", "o-ran-ru2/pynts"),  # Option 60
+            "end"
+        ])
+    )
+    print("[*] Sending DHCPDISCOVER...")
+    sendp(discover, iface="eth0", verbose=False)
+
+    print("[*] Sniffing for 5 seconds...")
+    time.sleep(5)
+
+    sniff_thread.stop()
+    results = sniff_thread.results
+    print(f"[+] Captured {len(results)} packets in total")
+
+    for pkt in results:
+        if handle_packet(pkt):
+            print("[+] Test SUCCESS - Received a valid DHCPOFFER.")
+            sys.exit(0)  # Exit with success code 0
+
+    print("[-] Test FAILED - No DHCPOFFER received.")
+    sys.exit(1)  # Exit with failure code 1
+
+if __name__ == "__main__":
+    test_dhcp()
diff --git a/solution/infra/docker-compose.yaml b/solution/infra/docker-compose.yaml
new file mode 100644 (file)
index 0000000..cc715d5
--- /dev/null
@@ -0,0 +1,59 @@
+################################################################################
+# Copyright 2025 highstreet technologies
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+services:
+  dhcp-server:
+    container_name: dhcp-server
+    privileged: true
+    cap_add:
+      - NET_ADMIN
+    image: docker.cloudsmith.io/isc/docker/kea-dhcp4:2.7.5
+    volumes:
+      - ./dhcp-server/kea-dhcp4.conf:/etc/kea/kea-dhcp4.conf:ro
+    restart: unless-stopped
+    environment:
+      - KEA_LOGGER_DESTINATION=stdout
+      - KEA_LOGGER_SEVERITY=INFO
+    networks:
+      dhcp:
+        ipv4_address: ${NETWORK_DHCP_CONTAINER_IPv4}
+
+  dhcp-tester:
+    build:
+      context: ./dhcp-tester
+    container_name: dhcp-tester
+    privileged: true
+    cap_add:
+      - NET_RAW
+      - NET_ADMIN
+    depends_on:
+      - dhcp-server  # ensures dhcp-server starts first
+    networks:
+      dhcp:
+        # no static IP, Docker will assign from ${NETWORK_SUBNET_DCN_IPv4}
+
+networks:
+  dhcp:
+    name: dhcp
+    driver: macvlan
+    driver_opts:
+      parent: ens34   # or whichever host interface you want
+      macvlan_mode: bridge
+    ipam:
+      config:
+        - subnet: ${NETWORK_SUBNET_DCN_IPv4}
+          gateway: ${NETWORK_GATEWAY_DHCP_IPv4}
+
index e9ae0a4..d9c3755 100644 (file)
@@ -37,7 +37,7 @@ VES_ENDPOINT_PASSWORD=sample1
 NEXUS3_DOCKER_REPO=nexus3.o-ran-sc.org:10004/o-ran-sc/
 LOCAL_DOCKER_REPO=
 
-PYNTS_VERSION=0.6.4
+PYNTS_VERSION=0.8.1
 
 NETCONF_USERNAME=netconf
 NETCONF_PASSWORD=netconf!
index 82fc102..f7dce3a 100755 (executable)
@@ -15,7 +15,6 @@
 #
 
 # no more versions needed! Compose spec supports all features w/o a version
-version: "3.8"
 services:
   odlux:
     image: ${SDNC_WEB_IMAGE}
@@ -53,10 +52,10 @@ services:
       - "controller.dcn.${HTTP_DOMAIN}:${HOST_IP}"
     healthcheck:
       test: wget --no-verbose --tries=1 --spider http://localhost:${SDNC_REST_PORT}/ready || exit 1
-      start_period: 60s
+      start_period: 10s
       interval: 10s
       timeout: 5s
-      retries: 5
+      retries: 10
     environment:
       ENABLE_ODL_CLUSTER: false
       ENABLE_OAUTH: ${SDNC_ENABLE_OAUTH}