From: Alex Stancu Date: Tue, 4 Feb 2025 10:22:34 +0000 (+0200) Subject: Add DHCP server container. X-Git-Url: https://gerrit.o-ran-sc.org/r/gitweb?a=commitdiff_plain;h=ee5d76de32f37803003efafb7397dd4fbc8765a8;p=oam.git Add DHCP server container. Issue-ID: OAM-427 Change-Id: I89a189256ae90303dfd381b32f91bc9ed014e3a0 Signed-off-by: Alex Stancu --- diff --git a/solution/README.md b/solution/README.md index b24ab0f..584c830 100644 --- a/solution/README.md +++ b/solution/README.md @@ -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 index 0000000..a5a7d2e --- /dev/null +++ b/solution/infra/.env @@ -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 index 0000000..82cf949 --- /dev/null +++ b/solution/infra/dhcp-server/kea-dhcp4.conf @@ -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 index 0000000..a865fd5 --- /dev/null +++ b/solution/infra/dhcp-tester/Dockerfile @@ -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 index 0000000..3bb799c --- /dev/null +++ b/solution/infra/dhcp-tester/docker-entrypoint.sh @@ -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 index 0000000..4dcd2bb --- /dev/null +++ b/solution/infra/dhcp-tester/test_dhcp.py @@ -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 index 0000000..cc715d5 --- /dev/null +++ b/solution/infra/docker-compose.yaml @@ -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} + diff --git a/solution/network/.env b/solution/network/.env index e9ae0a4..d9c3755 100644 --- a/solution/network/.env +++ b/solution/network/.env @@ -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! diff --git a/solution/smo/oam/docker-compose.yaml b/solution/smo/oam/docker-compose.yaml index 82fc102..f7dce3a 100755 --- a/solution/smo/oam/docker-compose.yaml +++ b/solution/smo/oam/docker-compose.yaml @@ -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}