From 73dd38a5ce4e47f354d14fd85eed23f484311db3 Mon Sep 17 00:00:00 2001 From: Yaqiang Zhu Date: Sat, 21 Oct 2023 00:52:05 +0800 Subject: [PATCH] [dhcp_server] Add dhcpservd to dhcp_server container (#16560) Why I did it Part implementation of dhcp_server. HLD: sonic-net/SONiC#1282. Add dhcpservd to dhcp_server container. How I did it Add installing required pkg (psutil) in Dockerfile. Add copying required file to container in Dockerfile (kea-dhcp related and dhcpservd related) Add critical_process and supervisor config. Add support for generating kea config (only in dhcpservd.py) and updating lease table (in dhcpservd.py and lease_update.sh) How to verify it Build image with setting INCLUDE_DHCP_SERVER to y and enabled dhcp_server feature after installed image, container start as expected. Enter container and found that all processes defined in supervisor configuration running as expected. Kill processes defined in critical_processes, container exist. --- dockers/docker-dhcp-server/Dockerfile.j2 | 21 +- dockers/docker-dhcp-server/critical_processes | 1 + dockers/docker-dhcp-server/docker_init.sh | 16 + .../docker-dhcp-server/kea-dhcp4-init.conf | 40 +++ dockers/docker-dhcp-server/kea-dhcp4.conf.j2 | 87 +++++ dockers/docker-dhcp-server/lease_update.sh | 12 + .../port-name-alias-map.txt.j2 | 5 + .../docker-dhcp-server/rsyslog/default.conf | 27 ++ .../rsyslog/rsyslog.conf.j2 | 96 +++++ dockers/docker-dhcp-server/start.sh | 19 + dockers/docker-dhcp-server/supervisord.conf | 25 +- rules/docker-dhcp-server.mk | 5 +- rules/sonic-dhcp-server.dep | 10 + rules/sonic-dhcp-server.mk | 10 + src/sonic-dhcp-server/.gitignore | 4 + .../dhcp_server/dhcp_cfggen.py | 330 ++++++++++++++++++ .../dhcp_server/dhcp_lease.py | 154 ++++++++ .../dhcp_server/dhcp_server_utils.py | 99 ++++++ .../dhcp_server/dhcpservd.py | 61 ++++ src/sonic-dhcp-server/setup.cfg | 20 ++ src/sonic-dhcp-server/setup.py | 49 +++ src/sonic-dhcp-server/tests/common_utils.py | 12 + src/sonic-dhcp-server/tests/conftest.py | 35 ++ src/sonic-dhcp-server/tests/kea-dhcp4.conf.j2 | 87 +++++ .../tests/test_data/kea-dhcp4.conf.j2 | 87 +++++ .../tests/test_data/kea-lease.csv | 9 + .../tests/test_data/mock_config_db.json | 159 +++++++++ .../mock_config_db_without_port_config.json | 149 ++++++++ .../tests/test_data/port-name-alias-map.txt | 3 + .../tests/test_dhcp_cfggen.py | 265 ++++++++++++++ .../tests/test_dhcp_lease.py | 103 ++++++ .../tests/test_dhcp_server_utils.py | 84 +++++ src/sonic-dhcp-server/tests/test_dhcpservd.py | 60 ++++ 33 files changed, 2139 insertions(+), 5 deletions(-) create mode 100644 dockers/docker-dhcp-server/kea-dhcp4-init.conf create mode 100644 dockers/docker-dhcp-server/kea-dhcp4.conf.j2 create mode 100644 dockers/docker-dhcp-server/lease_update.sh create mode 100644 dockers/docker-dhcp-server/port-name-alias-map.txt.j2 create mode 100644 dockers/docker-dhcp-server/rsyslog/default.conf create mode 100644 dockers/docker-dhcp-server/rsyslog/rsyslog.conf.j2 create mode 100755 dockers/docker-dhcp-server/start.sh create mode 100644 rules/sonic-dhcp-server.dep create mode 100644 rules/sonic-dhcp-server.mk create mode 100644 src/sonic-dhcp-server/.gitignore create mode 100755 src/sonic-dhcp-server/dhcp_server/dhcp_cfggen.py create mode 100644 src/sonic-dhcp-server/dhcp_server/dhcp_lease.py create mode 100644 src/sonic-dhcp-server/dhcp_server/dhcp_server_utils.py create mode 100644 src/sonic-dhcp-server/dhcp_server/dhcpservd.py create mode 100644 src/sonic-dhcp-server/setup.cfg create mode 100644 src/sonic-dhcp-server/setup.py create mode 100644 src/sonic-dhcp-server/tests/common_utils.py create mode 100644 src/sonic-dhcp-server/tests/conftest.py create mode 100644 src/sonic-dhcp-server/tests/kea-dhcp4.conf.j2 create mode 100644 src/sonic-dhcp-server/tests/test_data/kea-dhcp4.conf.j2 create mode 100644 src/sonic-dhcp-server/tests/test_data/kea-lease.csv create mode 100644 src/sonic-dhcp-server/tests/test_data/mock_config_db.json create mode 100644 src/sonic-dhcp-server/tests/test_data/mock_config_db_without_port_config.json create mode 100644 src/sonic-dhcp-server/tests/test_data/port-name-alias-map.txt create mode 100644 src/sonic-dhcp-server/tests/test_dhcp_cfggen.py create mode 100644 src/sonic-dhcp-server/tests/test_dhcp_lease.py create mode 100644 src/sonic-dhcp-server/tests/test_dhcp_server_utils.py create mode 100644 src/sonic-dhcp-server/tests/test_dhcpservd.py diff --git a/dockers/docker-dhcp-server/Dockerfile.j2 b/dockers/docker-dhcp-server/Dockerfile.j2 index af999e74a4..9257ee38c7 100755 --- a/dockers/docker-dhcp-server/Dockerfile.j2 +++ b/dockers/docker-dhcp-server/Dockerfile.j2 @@ -3,6 +3,7 @@ FROM docker-config-engine-bullseye-{{DOCKER_USERNAME}}:{{DOCKER_USERTAG}} ARG docker_container_name ARG image_version +RUN [ -f /etc/rsyslog.conf ] && sed -ri "s/%syslogtag%/$docker_container_name#%syslogtag%/;" /etc/rsyslog.conf ## Make apt-get non-interactive ENV DEBIAN_FRONTEND=noninteractive @@ -13,6 +14,7 @@ ENV IMAGE_VERSION=$image_version RUN apt-get update && \ apt-get install -f -y \ tcpdump \ + python3-dev \ # For kea build environment automake \ libtool \ @@ -47,7 +49,9 @@ RUN echo "/usr/local/lib/kea/hooks" > /etc/ld.so.conf.d/kea.conf && \ RUN cd /usr/local/sbin && rm -f kea-admin kea-ctrl-agent kea-dhcp-ddns kea-dhcp6 keactrl # Remove hook lib we don't need RUN cd /usr/local/lib/kea/hooks && rm -f libdhcp_bootp.so libdhcp_flex_option.so libdhcp_stat_cmds.so -# RUN pip3 install psutil +RUN pip3 install psutil +# TODO issue on remote rsyslog server in non-host container +RUN rm -f /etc/supervisor/conf.d/containercfgd.conf {% if docker_dhcp_server_debs.strip() -%} # Copy locally-built Debian package dependencies @@ -57,12 +61,21 @@ RUN cd /usr/local/lib/kea/hooks && rm -f libdhcp_bootp.so libdhcp_flex_option.so {{ install_debian_packages(docker_dhcp_server_debs.split(' ')) }} {%- endif %} +{% if docker_dhcp_server_whls.strip() %} +# Copy locally-built Python wheel dependencies +{{ copy_files("python-wheels/", docker_dhcp_server_whls.split(' '), "/python-wheels/") }} + +# Install locally-built Python wheel dependencies +{{ install_python_wheels(docker_dhcp_server_whls.split(' ')) }} +{% endif %} + # Remove build stuff we don't need RUN apt-get remove -y devscripts \ automake \ libtool \ pkg-config \ build-essential \ + python3-dev \ ccache RUN apt-get clean -y && \ @@ -70,10 +83,14 @@ RUN apt-get clean -y && \ apt-get autoremove -y && \ rm -rf /debs -COPY ["docker_init.sh", "/usr/bin/"] +COPY ["docker_init.sh", "start.sh", "/usr/bin/"] COPY ["supervisord.conf", "/etc/supervisor/conf.d/"] COPY ["files/supervisor-proc-exit-listener", "/usr/bin"] +COPY ["port-name-alias-map.txt.j2", "rsyslog/rsyslog.conf.j2", "kea-dhcp4.conf.j2", "/usr/share/sonic/templates/"] COPY ["critical_processes", "/etc/supervisor/"] +COPY ["lease_update.sh", "/etc/kea/"] +COPY ["kea-dhcp4-init.conf", "/etc/kea/kea-dhcp4.conf"] COPY ["cli", "/cli/"] +COPY ["rsyslog/default.conf", "/etc/rsyslog.d"] ENTRYPOINT ["/usr/bin/docker_init.sh"] diff --git a/dockers/docker-dhcp-server/critical_processes b/dockers/docker-dhcp-server/critical_processes index e69de29bb2..cff4ef95a9 100644 --- a/dockers/docker-dhcp-server/critical_processes +++ b/dockers/docker-dhcp-server/critical_processes @@ -0,0 +1 @@ +group:dhcp-server-ipv4 diff --git a/dockers/docker-dhcp-server/docker_init.sh b/dockers/docker-dhcp-server/docker_init.sh index 5f5d397213..21ff37059a 100755 --- a/dockers/docker-dhcp-server/docker_init.sh +++ b/dockers/docker-dhcp-server/docker_init.sh @@ -1,5 +1,21 @@ #!/usr/bin/env bash + +# Generate supervisord config file +mkdir -p /etc/supervisor/conf.d/ +# Generate kea folder +mkdir -p /etc/kea/ +udp_server_ip=$(ip -j -4 addr list lo scope host | jq -r -M '.[0].addr_info[0].local') +hostname=$(hostname) +# Generate the following files from templates: +# port-to-alias name map +sonic-cfggen -d -t /usr/share/sonic/templates/rsyslog.conf.j2 \ + -a "{\"udp_server_ip\": \"$udp_server_ip\", \"hostname\": \"$hostname\"}" \ + > /etc/rsyslog.conf +sonic-cfggen -d -t /usr/share/sonic/templates/port-name-alias-map.txt.j2,/tmp/port-name-alias-map.txt + +# Make the script that waits for all interfaces to come up executable +chmod +x /etc/kea/lease_update.sh /usr/bin/start.sh # The docker container should start this script as PID 1, so now that supervisord is # properly configured, we exec /usr/local/bin/supervisord so that it runs as PID 1 for the # duration of the container's lifetime diff --git a/dockers/docker-dhcp-server/kea-dhcp4-init.conf b/dockers/docker-dhcp-server/kea-dhcp4-init.conf new file mode 100644 index 0000000000..63733d3ef8 --- /dev/null +++ b/dockers/docker-dhcp-server/kea-dhcp4-init.conf @@ -0,0 +1,40 @@ +{ + "Dhcp4": { + "hooks-libraries": [ + { + "library": "/usr/local/lib/kea/hooks/libdhcp_run_script.so", + "parameters": { + "name": "/etc/kea/lease_update.sh", + "sync": false + } + } + ], + "interfaces-config": { + "interfaces": ["eth0"] + }, + "control-socket": { + "socket-type": "unix", + "socket-name": "/run/kea/kea4-ctrl-socket" + }, + "lease-database": { + "type": "memfile", + "persist": true, + "name": "/tmp/kea-lease.csv", + "lfc-interval": 3600 + }, + "subnet4": [], + "loggers": [ + { + "name": "kea-dhcp4", + "output_options": [ + { + "output": "/tmp/kea-dhcp.log", + "pattern": "%-5p %m\n" + } + ], + "severity": "INFO", + "debuglevel": 0 + } + ] + } +} diff --git a/dockers/docker-dhcp-server/kea-dhcp4.conf.j2 b/dockers/docker-dhcp-server/kea-dhcp4.conf.j2 new file mode 100644 index 0000000000..27fd1a1d1f --- /dev/null +++ b/dockers/docker-dhcp-server/kea-dhcp4.conf.j2 @@ -0,0 +1,87 @@ +{%- set default_lease_time = 900 -%} +{ + "Dhcp4": { + "hooks-libraries": [ + { + "library": "/usr/local/lib/kea/hooks/libdhcp_run_script.so", + "parameters": { + "name": "{{ lease_update_script_path }}", + "sync": false + } + } + ], + "interfaces-config": { + "interfaces": [ + "eth0" + ] + }, + "control-socket": { + "socket-type": "unix", + "socket-name": "/run/kea/kea4-ctrl-socket" + }, + "lease-database": { + "type": "memfile", + "persist": true, + "name": "{{ lease_path }}", + "lfc-interval": 3600 + }, + "subnet4": [ +{%- set add_subnet_preceding_comma = { 'flag': False } %} +{%- for subnet_info in subnets %} + {%- if add_subnet_preceding_comma.flag -%},{%- endif -%} + {%- set _dummy = add_subnet_preceding_comma.update({'flag': True}) %} + { + "subnet": "{{ subnet_info["subnet"] }}", + "pools": [ + {%- set add_pool_preceding_comma = { 'flag': False } %} + {%- for pool in subnet_info["pools"] %} + {%- if add_pool_preceding_comma.flag -%},{%- endif -%} + {%- set _dummy = add_pool_preceding_comma.update({'flag': True}) %} + { + "pool": "{{ pool["range"] }}", + "client-class": "{{ pool["client_class"] }}" + } + {%- endfor%} + ], + "option-data": [ + { + "name": "routers", + "data": "{{ subnet_info["gateway"] if "gateway" in subnet_info else subnet_info["server_id"] }}" + }, + { + "name": "dhcp-server-identifier", + "data": "{{ subnet_info["server_id"] }}" + } + ], + "valid-lifetime": {{ subnet_info["lease_time"] if "lease_time" in subnet_info else default_lease_time }}, + "reservations": [] + } +{%- endfor %} + ], + "loggers": [ + { + "name": "kea-dhcp4", + "output_options": [ + { + "output": "/var/log/kea-dhcp.log", + "pattern": "%-5p %m\n" + } + ], + "severity": "INFO", + "debuglevel": 0 + } + ]{%- if client_classes -%}, + "client-classes": [ + {%- set add_preceding_comma = { 'flag': False } %} + {%- for class in client_classes %} + {%- if add_preceding_comma.flag -%},{%- endif -%} + {%- set _dummy = add_preceding_comma.update({'flag': True}) %} + { + "name": "{{ class["name"] }}", + "test": "{{ class["condition"] }}" + } + {%- endfor %} + ] + {%- endif %} + } +} diff --git a/dockers/docker-dhcp-server/lease_update.sh b/dockers/docker-dhcp-server/lease_update.sh new file mode 100644 index 0000000000..9fdb8f4f3e --- /dev/null +++ b/dockers/docker-dhcp-server/lease_update.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# This script would run once kea-dhcp4 lease change (defined in kea-dhcp4.conf), +# it is to find running process dhcpservd.py, and send SIGUSR1 signal to this +# process to inform it to update lease table in state_db (defined in dhcpservd.py) + +pid=`ps aux | grep 'dhcpservd' | grep -nv 'grep' | awk '{print $2}'` +if [ -z "$pid" ]; then + logger -p daemon.error Cannot find running dhcpservd.py. +else + # Send SIGUSR1 signal to dhcpservd.py + kill -s 10 ${pid} +fi diff --git a/dockers/docker-dhcp-server/port-name-alias-map.txt.j2 b/dockers/docker-dhcp-server/port-name-alias-map.txt.j2 new file mode 100644 index 0000000000..b2290a9ffb --- /dev/null +++ b/dockers/docker-dhcp-server/port-name-alias-map.txt.j2 @@ -0,0 +1,5 @@ +{# Generate port name-alias map for isc-dhcp-relay to parse. Each line contains one #} +{# name-alias pair of the form " " #} +{% for port, config in PORT.items() %} + {{- port }} {% if "alias" in config %}{{ config["alias"] }}{% else %}{{ port }}{% endif %} {{- "\n" -}} +{% endfor -%} diff --git a/dockers/docker-dhcp-server/rsyslog/default.conf b/dockers/docker-dhcp-server/rsyslog/default.conf new file mode 100644 index 0000000000..77609410c5 --- /dev/null +++ b/dockers/docker-dhcp-server/rsyslog/default.conf @@ -0,0 +1,27 @@ +# +# First some standard log files. Log by facility. +# + +# Log all facilities to /var/log/syslog except cron, auth +# and authpriv. They are noisy - log them to their own files +*.*;cron,auth,authpriv.none -/var/log/syslog +auth,authpriv.* /var/log/auth.log +cron.* /var/log/cron.log + +# +# Emergencies are sent to everybody logged in. +# +*.emerg :omusrmsg:* + +# The named pipe /dev/xconsole is for the `xconsole' utility. To use it, +# you must invoke `xconsole' with the `-file' option: +# +# $ xconsole -file /dev/xconsole [...] +# +# NOTE: adjust the list below, or you'll go crazy if you have a reasonably +# busy site.. +# +#daemon.*;mail.*;\ +# news.err;\ +# *.=debug;*.=info;\ +# *.=notice;*.=warn |/dev/xconsole diff --git a/dockers/docker-dhcp-server/rsyslog/rsyslog.conf.j2 b/dockers/docker-dhcp-server/rsyslog/rsyslog.conf.j2 new file mode 100644 index 0000000000..664a6a5fb9 --- /dev/null +++ b/dockers/docker-dhcp-server/rsyslog/rsyslog.conf.j2 @@ -0,0 +1,96 @@ +############################################################################### +# Managed by Ansible +# file: ansible/roles/acs/templates/rsyslog.conf.j2 +############################################################################### +# +# /etc/rsyslog.conf Configuration file for rsyslog. +# +# For more information see +# /usr/share/doc/rsyslog-doc/html/rsyslog_conf.html + + +################# +#### MODULES #### +################# + +$ModLoad imuxsock # provides support for local system logging + +{% set gconf = (SYSLOG_CONFIG | d({})).get('GLOBAL', {}) -%} +{% set rate_limit_interval = gconf.get('rate_limit_interval') %} +{% set rate_limit_burst = gconf.get('rate_limit_burst') %} + +{% if rate_limit_interval is not none %} +$SystemLogRateLimitInterval {{ rate_limit_interval }} +{% endif %} +{% if rate_limit_burst is not none %} +$SystemLogRateLimitBurst {{ rate_limit_burst }} +{% endif %} + +$ModLoad imklog # provides kernel logging support +#$ModLoad immark # provides --MARK-- message capability + +# provides UDP syslog reception +$ModLoad imudp +$UDPServerAddress {{udp_server_ip}} #bind to localhost before udp server run +$UDPServerRun 514 + +# provides TCP syslog reception +#$ModLoad imtcp +#$InputTCPServerRun 514 + + +########################### +#### GLOBAL DIRECTIVES #### +########################### +{% set format = gconf.get('format', 'standard') -%} +{% set fw_name = gconf.get('welf_firewall_name', hostname) -%} +# +# Use traditional timestamp format. +# To enable high precision timestamps, comment out the following line. +# +#$ActionFileDefaultTemplate RSYSLOG_TraditionalFileFormat + +# Define a custom template +$template SONiCFileFormat,"%TIMESTAMP%.%timestamp:::date-subseconds% %HOSTNAME% %syslogseverity-text:::uppercase% dhcp_server#%syslogtag%%msg:::sp-if-no-1st-sp%%msg:::drop-last-lf%\n" +$ActionFileDefaultTemplate SONiCFileFormat + +template(name="WelfRemoteFormat" type="string" string="%TIMESTAMP% id=firewall time=\"%timereported\ +:::date-year%-%timereported:::date-month%-%timereported:::date-day% %timereported:::date-hour%:%timereported:::date-minute%:%timereported\ +:::date-second%\" fw=\"{{ fw_name }}\" pri=%syslogpriority% msg=\"%syslogtag%%msg:::sp-if-no-1st-sp%%msg:::drop-last-lf%\"\n") + +# +# Set the default permissions for all log files. +# +$FileOwner root +$FileGroup adm +$FileCreateMode 0640 +$DirCreateMode 0755 +$Umask 0022 + +# +# Where to place spool and state files +# +$WorkDirectory /var/spool/rsyslog + +# +# Include all config files in /etc/rsyslog.d/ +# +$IncludeConfig /etc/rsyslog.d/*.conf + +# +# Suppress duplicate messages and report "message repeated n times" +# +$RepeatedMsgReduction on + +############### +#### RULES #### +############### + +# +# Remote syslog logging +# + +# The omfwd plug-in provides the core functionality of traditional message +# forwarding via UDP and plain TCP. It is a built-in module that does not need +# to be loaded. +# TODO rsyslog issue in bridge mode container, don't update to remote server for now diff --git a/dockers/docker-dhcp-server/start.sh b/dockers/docker-dhcp-server/start.sh new file mode 100755 index 0000000000..46b8eb503a --- /dev/null +++ b/dockers/docker-dhcp-server/start.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +if [ "${RUNTIME_OWNER}" == "" ]; then + RUNTIME_OWNER="kube" +fi + +# This script is to basicly check for if this starting-container can be allowed +# to run based on current state, and owner & version of this starting container. +# If allowed, update feature info in state_db and then processes in supervisord.conf +# after this process can start up. +CTR_SCRIPT="/usr/share/sonic/scripts/container_startup.py" +if test -f ${CTR_SCRIPT} +then + ${CTR_SCRIPT} -f dhcp_server -o ${RUNTIME_OWNER} -v ${IMAGE_VERSION} +fi + +TZ=$(cat /etc/timezone) +rm -rf /etc/localtime +ln -sf /usr/share/zoneinfo/$TZ /etc/localtime diff --git a/dockers/docker-dhcp-server/supervisord.conf b/dockers/docker-dhcp-server/supervisord.conf index b4ee4f477d..ea958b9e7c 100644 --- a/dockers/docker-dhcp-server/supervisord.conf +++ b/dockers/docker-dhcp-server/supervisord.conf @@ -13,7 +13,7 @@ events=PROCESS_STATE buffer_size=1024 [eventlistener:supervisor-proc-exit-listener] -command=/usr/bin/supervisor-proc-exit-listener --container-name dhcp_server +command=/usr/bin/supervisor-proc-exit-listener --container-name dhcp_server --use-unix-socket-path events=PROCESS_STATE_EXITED,PROCESS_STATE_RUNNING autostart=true autorestart=unexpected @@ -38,3 +38,26 @@ stdout_logfile=syslog stderr_logfile=syslog dependent_startup=true dependent_startup_wait_for=rsyslogd:running + +[group:dhcp-server-ipv4] +programs=dhcpservd,kea-dhcp4 + +[program:dhcpservd] +command=/usr/local/bin/dhcpservd +priority=3 +autostart=false +autorestart=false +stdout_logfile=syslog +stderr_logfile=syslog +dependent_startup=true +dependent_startup_wait_for=start:exited + +[program:kea-dhcp4] +command=/usr/local/sbin/kea-dhcp4 -c /etc/kea/kea-dhcp4.conf +priority=3 +autostart=false +autorestart=false +stdout_logfile=syslog +stderr_logfile=syslog +dependent_startup=true +dependent_startup_wait_for=start:exited diff --git a/rules/docker-dhcp-server.mk b/rules/docker-dhcp-server.mk index af600b81ec..a65a6a3ca2 100644 --- a/rules/docker-dhcp-server.mk +++ b/rules/docker-dhcp-server.mk @@ -13,8 +13,8 @@ $(DOCKER_DHCP_SERVER)_DBG_IMAGE_PACKAGES = $($(DOCKER_CONFIG_ENGINE_BULLSEYE)_DB $(DOCKER_DHCP_SERVER)_LOAD_DOCKERS = $(DOCKER_CONFIG_ENGINE_BULLSEYE) -$(DOCKER_DHCP_SERVER)_INSTALL_PYTHON_WHEELS = $(SONIC_UTILITIES_PY3) $(DOCKER_DHCP_SERVER)_INSTALL_DEBS = $(PYTHON3_SWSSCOMMON) +$(DOCKER_DHCP_SERVER)_PYTHON_WHEELS += $(SONIC_DHCP_SERVER_PY3) SONIC_DOCKER_IMAGES += $(DOCKER_DHCP_SERVER) SONIC_DOCKER_DBG_IMAGES += $(DOCKER_DHCP_SERVER_DBG) @@ -40,7 +40,7 @@ $(DOCKER_DHCP_SERVER)_PACKAGE_NAME = dhcp-server $(DOCKER_MACSEC)_SERVICE_REQUIRES = updategraph $(DOCKER_MACSEC)_SERVICE_AFTER = swss syncd -$(DOCKER_DHCP_SERVER)_CONTAINER_PRIVILEGED = true +$(DOCKER_DHCP_SERVER)_CONTAINER_PRIVILEGED = false $(DOCKER_DHCP_SERVER)_CONTAINER_VOLUMES += /etc/sonic:/etc/sonic:ro $(DOCKER_DHCP_SERVER)_CONTAINER_TMPFS += /tmp/ $(DOCKER_DHCP_SERVER)_CONTAINER_TMPFS += /var/tmp/ @@ -48,5 +48,6 @@ $(DOCKER_DHCP_SERVER)_CONTAINER_TMPFS += /var/tmp/ $(DOCKER_DHCP_SERVER)_CLI_CONFIG_PLUGIN = /cli/config/plugins/dhcp_server.py $(DOCKER_DHCP_SERVER)_CLI_SHOW_PLUGIN = /cli/show/plugins/show_dhcp_server.py $(DOCKER_DHCP_SERVER)_CLI_CLEAR_PLUGIN = /cli/clear/plugins/clear_dhcp_server.py +$(DOCKER_DHCP_SERVER)_SUPPORT_RATE_LIMIT = false $(DOCKER_DHCP_SERVER)_FILES += $(SUPERVISOR_PROC_EXIT_LISTENER_SCRIPT) diff --git a/rules/sonic-dhcp-server.dep b/rules/sonic-dhcp-server.dep new file mode 100644 index 0000000000..891fffbe20 --- /dev/null +++ b/rules/sonic-dhcp-server.dep @@ -0,0 +1,10 @@ +SPATH := $($(SONIC_DHCPSERVD)_SRC_PATH) +DEP_FILES := $(SONIC_COMMON_FILES_LIST) rules/sonic-dhcp-server.mk rules/sonic-dhcp-server.dep +DEP_FILES += $(SONIC_COMMON_BASE_FILES_LIST) +SMDEP_FILES := $(addprefix $(SPATH)/,$(shell cd $(SPATH) && git ls-files)) + +$(SONIC_DHCPSERVD)_CACHE_MODE := GIT_CONTENT_SHA +$(SONIC_DHCPSERVD)_DEP_FLAGS := $(SONIC_COMMON_FLAGS_LIST) +$(SONIC_DHCPSERVD)_DEP_FILES := $(DEP_FILES) +$(SONIC_DHCPSERVD)_SMDEP_FILES := $(SMDEP_FILES) +$(SONIC_DHCPSERVD)_SMDEP_PATHS := $(SPATH) diff --git a/rules/sonic-dhcp-server.mk b/rules/sonic-dhcp-server.mk new file mode 100644 index 0000000000..941127204c --- /dev/null +++ b/rules/sonic-dhcp-server.mk @@ -0,0 +1,10 @@ +# sonic-dhcp-server package + +SONIC_DHCP_SERVER_PY3 = sonic_dhcp_server-1.0-py3-none-any.whl +$(SONIC_DHCP_SERVER_PY3)_SRC_PATH = $(SRC_PATH)/sonic-dhcp-server +$(SONIC_DHCP_SERVER_PY3)_DEPENDS += $(SONIC_PY_COMMON_PY3) +$(SONIC_DHCP_SERVER_PY3)_DEBS_DEPENDS = $(LIBSWSSCOMMON) $(PYTHON3_SWSSCOMMON) +$(SONIC_DHCP_SERVER_PY3)_PYTHON_VERSION = 3 +ifeq ($(INCLUDE_DHCP_SERVER), y) +SONIC_PYTHON_WHEELS += $(SONIC_DHCP_SERVER_PY3) +endif diff --git a/src/sonic-dhcp-server/.gitignore b/src/sonic-dhcp-server/.gitignore new file mode 100644 index 0000000000..8f5a21dd76 --- /dev/null +++ b/src/sonic-dhcp-server/.gitignore @@ -0,0 +1,4 @@ +.eggs/ +build/ +dist/ +sonic_dhcpservd.egg-info/ diff --git a/src/sonic-dhcp-server/dhcp_server/dhcp_cfggen.py b/src/sonic-dhcp-server/dhcp_server/dhcp_cfggen.py new file mode 100755 index 0000000000..4fca71a383 --- /dev/null +++ b/src/sonic-dhcp-server/dhcp_server/dhcp_cfggen.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python + +import ipaddress +import json +import os +import syslog + +from jinja2 import Environment, FileSystemLoader +from .dhcp_server_utils import merge_intervals + +PORT_MAP_PATH = "/tmp/port-name-alias-map.txt" +UNICODE_TYPE = str +DHCP_SERVER_IPV4 = "DHCP_SERVER_IPV4" +DHCP_SERVER_IPV4_CUSTOMIZED_OPTIONS = "DHCP_SERVER_IPV4_CUSTOMIZED_OPTIONS" +DHCP_SERVER_IPV4_RANGE = "DHCP_SERVER_IPV4_RANGE" +DHCP_SERVER_IPV4_PORT = "DHCP_SERVER_IPV4_PORT" +DHCP_SERVER_IPV4_LEASE = "DHCP_SERVER_IPV4_LEASE" +LEASE_UPDATE_SCRIPT_PATH = "/etc/kea/lease_update.sh" +DEFAULT_LEASE_PATH = "/tmp/kea-lease.csv" +KEA_DHCP4_CONF_TEMPLATE_PATH = "/usr/share/sonic/templates/kea-dhcp4.conf.j2" +# Default lease time of DHCP +DEFAULT_LEASE_TIME = 900 + + +class DhcpServCfgGenerator(object): + port_alias_map = {} + lease_update_script_path = "" + lease_path = "" + + def __init__(self, dhcp_db_connector, lease_path=DEFAULT_LEASE_PATH, port_map_path=PORT_MAP_PATH, + lease_update_script_path=LEASE_UPDATE_SCRIPT_PATH, + kea_conf_template_path=KEA_DHCP4_CONF_TEMPLATE_PATH): + self.db_connector = dhcp_db_connector + self.lease_path = lease_path + self.lease_update_script_path = lease_update_script_path + # Read port alias map file, this file is render after container start, so it would not change any more + self._parse_port_map_alias(port_map_path) + # Get kea config template + self._get_render_template(kea_conf_template_path) + + def generate(self): + """ + Generate dhcp server config + Returns: + config dict + """ + # Generate from running config_db + # Get host name + device_metadata = self.db_connector.get_config_db_table("DEVICE_METADATA") + hostname = self._parse_hostname(device_metadata) + # Get ip information of vlan + vlan_interface = self.db_connector.get_config_db_table("VLAN_INTERFACE") + vlan_member_table = self.db_connector.get_config_db_table("VLAN_MEMBER") + vlan_interfaces, vlan_members = self._parse_vlan(vlan_interface, vlan_member_table) + + dhcp_server_ipv4, customized_options_ipv4, range_ipv4, port_ipv4 = self._get_dhcp_ipv4_tables_from_db() + # Parse range table + ranges = self._parse_range(range_ipv4) + + # TODO Add support for customizing options + + # Parse port table + port_ips = self._parse_port(port_ipv4, vlan_interfaces, vlan_members, ranges) + render_obj = self._construct_obj_for_template(dhcp_server_ipv4, port_ips, hostname) + + return self._render_config(render_obj) + + def _render_config(self, render_obj): + output = self.kea_template.render(render_obj) + return output + + def _parse_vlan(self, vlan_interface, vlan_member): + vlan_interfaces = self._get_vlan_ipv4_interface(vlan_interface.keys()) + vlan_members = vlan_member.keys() + return vlan_interfaces, vlan_members + + def _parse_hostname(self, device_metadata): + localhost_entry = device_metadata.get("localhost", {}) + if localhost_entry is None or "hostname" not in localhost_entry: + syslog.syslog(syslog.LOG_ERR, "Cannot get hostname") + raise Exception("Cannot get hostname") + return localhost_entry["hostname"] + + def _get_render_template(self, kea_conf_template_path): + # Semgrep does not allow to use jinja2 directly, but we do need jinja2 for SONiC + env = Environment(loader=FileSystemLoader(os.path.dirname(kea_conf_template_path))) # nosemgrep + self.kea_template = env.get_template(os.path.basename(kea_conf_template_path)) + + def _parse_port_map_alias(self, port_map_path): + with open(port_map_path, "r") as file: + lines = file.readlines() + for line in lines: + splits = line.strip().split(" ") + if len(splits) != 2: + continue + self.port_alias_map[splits[0]] = splits[1] + + def _construct_obj_for_template(self, dhcp_server_ipv4, port_ips, hostname): + subnets = [] + client_classes = [] + for dhcp_interface_name, dhcp_config in dhcp_server_ipv4.items(): + if "state" not in dhcp_config or dhcp_config["state"] != "enabled": + continue + if dhcp_config["mode"] == "PORT": + if dhcp_interface_name not in port_ips: + syslog.syslog(syslog.LOG_WARNING, "Cannot get DHCP port config for {}" + .format(dhcp_interface_name)) + continue + for dhcp_interface_ip, port_config in port_ips[dhcp_interface_name].items(): + pools = [] + for port_name, ip_ranges in port_config.items(): + ip_range = None + for ip_range in ip_ranges: + client_class = "{}:{}".format(hostname, port_name) + ip_range = { + "range": "{} - {}".format(ip_range[0], ip_range[1]), + "client_class": client_class + } + pools.append(ip_range) + if ip_range is not None: + class_len = len(client_class) + client_classes.append({ + "name": client_class, + "condition": "substring(relay4[1].hex, -{}, {}) == '{}'".format(class_len, class_len, + client_class) + }) + subnet_obj = { + "subnet": str(ipaddress.ip_network(dhcp_interface_ip, strict=False)), + "pools": pools, + "gateway": dhcp_config["gateway"], + "server_id": dhcp_interface_ip.split("/")[0], + "lease_time": dhcp_config["lease_time"] + } + subnets.append(subnet_obj) + render_obj = { + "subnets": subnets, + "client_classes": client_classes, + "lease_update_script_path": self.lease_update_script_path, + "lease_path": self.lease_path + } + return render_obj + + def _get_dhcp_ipv4_tables_from_db(self): + """ + Get DHCP Server IPv4 related table from config_db. + Returns: + Four table objects. + """ + dhcp_server_ipv4 = self.db_connector.get_config_db_table(DHCP_SERVER_IPV4) + customized_options_ipv4 = self.db_connector.get_config_db_table(DHCP_SERVER_IPV4_CUSTOMIZED_OPTIONS) + range_ipv4 = self.db_connector.get_config_db_table(DHCP_SERVER_IPV4_RANGE) + port_ipv4 = self.db_connector.get_config_db_table(DHCP_SERVER_IPV4_PORT) + return dhcp_server_ipv4, customized_options_ipv4, range_ipv4, port_ipv4 + + def _get_vlan_ipv4_interface(self, vlan_interface_keys): + """ + Get ipv4 info of vlans + Args: + vlan_interface_keys: Keys of vlan_interfaces, sample: + [ + "Vlan1000|192.168.0.1/21", + "Vlan1000|fc02:1000::1/64" + ] + Returns: + Vlans infomation, sample: + { + 'Vlan1000': [{ + 'network': IPv4Network('192.168.0.0/24'), + 'ip': '192.168.0.1/24' + }] + } + """ + ret = {} + for key in vlan_interface_keys: + splits = key.split("|") + # Skip with no ip address + if len(splits) != 2: + continue + network = ipaddress.ip_network(UNICODE_TYPE(splits[1]), False) + # Skip ipv6 + if network.version != 4: + continue + if splits[0] not in ret: + ret[splits[0]] = [] + ret[splits[0]].append({"network": network, "ip": splits[1]}) + return ret + + def _parse_range(self, range_ipv4): + """ + Parse content in DHCP_SERVER_IPV4_RANGE table to below format: + { + 'range2': [IPv4Address('192.168.0.3'), IPv4Address('192.168.0.6')], + 'range1': [IPv4Address('192.168.0.2'), IPv4Address('192.168.0.5')], + 'range3': [IPv4Address('192.168.0.10'), IPv4Address('192.168.0.10')] + } + Args: + range_ipv4: Table object or dict of range. + """ + ranges = {} + for range in list(range_ipv4.keys()): + curr_range = range_ipv4.get(range, {}).get("range", {}) + if len(curr_range) != 2: + syslog.syslog(syslog.LOG_WARNING, f"Length of {curr_range} != 2") + continue + address_1 = ipaddress.ip_address(curr_range[0]) + address_2 = ipaddress.ip_address(curr_range[1]) + # To make sure order of range is correct + range_start = address_1 if address_1 < address_2 else address_2 + range_end = address_2 if address_1 < address_2 else address_1 + ranges[range] = [range_start, range_end] + + return ranges + + def _match_range_network(self, dhcp_interface, dhcp_interface_name, port, range, port_ips): + """ + Loop the IP of the dhcp interface and find the network that target range is in this network. And to construct + below data to record range - port map + { + 'Vlan1000': { + '192.168.0.1/24': { + 'etp2': [ + [IPv4Address('192.168.0.7'), IPv4Address('192.168.0.7')] + ] + } + } + } + Args: + dhcp_interface: Ip and network information of current DHCP interface, sample: + [{ + 'network': IPv4Network('192.168.0.0/24'), + 'ip': '192.168.0.1/24' + }] + dhcp_interface_name: Name of DHCP interface. + port: Name of DHCP member port. + range: Ip Range, sample: + [IPv4Address('192.168.0.2'), IPv4Address('192.168.0.5')] + """ + for dhcp_interface_ip in dhcp_interface: + if not range[0] in dhcp_interface_ip["network"] or \ + not range[1] in dhcp_interface_ip["network"]: + continue + dhcp_interface_ip_str = dhcp_interface_ip["ip"] + if dhcp_interface_ip_str not in port_ips[dhcp_interface_name]: + port_ips[dhcp_interface_name][dhcp_interface_ip_str] = {} + if port not in port_ips[dhcp_interface_name][dhcp_interface_ip_str]: + port_ips[dhcp_interface_name][dhcp_interface_ip_str][port] = [] + port_ips[dhcp_interface_name][dhcp_interface_ip_str][port].append([range[0], range[1]]) + break + + def _parse_port(self, port_ipv4, vlan_interfaces, vlan_members, ranges): + """ + Parse content in DHCP_SERVER_IPV4_PORT table to below format, which indicate ip ranges assign to interface. + Args: + port_ipv4: Table object. + vlan_interfaces: Vlan information, sample: + { + 'Vlan1000': [{ + 'network': IPv4Network('192.168.0.0/24'), + 'ip': '192.168.0.1/24' + }] + } + vlan_members: List of vlan members + ranges: Dict of ranges + Returns: + Dict of dhcp conf, sample: + { + 'Vlan1000': { + '192.168.0.1/24': { + 'etp2': [ + ['192.168.0.7', '192.168.0.7'] + ], + 'etp3': [ + ['192.168.0.2', '192.168.0.6'], + ['192.168.0.10', '192.168.0.10'] + ] + } + } + } + """ + port_ips = {} + ip_ports = {} + for port_key in list(port_ipv4.keys()): + port_config = port_ipv4.get(port_key, {}) + # Cannot specify both 'ips' and 'ranges' + if "ips" in port_config and len(port_config["ips"]) != 0 and "ranges" in port_config \ + and len(port_config["ranges"]) != 0: + syslog.syslog(syslog.LOG_WARNING, f"Port config for {port_key} contains both ips and ranges, skip") + continue + splits = port_key.split("|") + # Skip port not in correct vlan + if port_key not in vlan_members: + syslog.syslog(syslog.LOG_WARNING, f"Port {splits[1]} is not in {splits[0]}") + continue + # Get dhcp interface name like Vlan1000 + dhcp_interface_name = splits[0] + # Get dhcp member interface name like etp1 + if splits[1] not in self.port_alias_map: + syslog.syslog(syslog.LOG_WARNING, f"Cannot find {splits[1]} in port_alias_map") + continue + port = self.port_alias_map[splits[1]] + if dhcp_interface_name not in port_ips: + port_ips[dhcp_interface_name] = {} + # Get ip information of Vlan + dhcp_interface = vlan_interfaces[dhcp_interface_name] + + for dhcp_interface_ip in dhcp_interface: + ip_ports[str(dhcp_interface_ip["network"])] = dhcp_interface_name + + if "ips" in port_config and len(port_config["ips"]) != 0: + for ip in set(port_config["ips"]): + ip_address = ipaddress.ip_address(ip) + # Loop the IP of the dhcp interface and find the network that target ip is in this network. + self._match_range_network(dhcp_interface, dhcp_interface_name, port, [ip_address, ip_address], + port_ips) + if "ranges" in port_config and len(port_config["ranges"]) != 0: + for range_name in list(port_config["ranges"]): + if range_name not in ranges: + syslog.syslog(syslog.LOG_WARNING, f"Range {range_name} is not in range table, skip") + continue + range = ranges[range_name] + # Loop the IP of the dhcp interface and find the network that target range is in this network. + self._match_range_network(dhcp_interface, dhcp_interface_name, port, range, port_ips) + # Merge ranges to avoid overlap + for dhcp_interface_name, value in port_ips.items(): + for dhcp_interface_ip, port_range in value.items(): + for port_name, ip_range in port_range.items(): + ranges = merge_intervals(ip_range) + ranges = [[str(range[0]), str(range[1])] for range in ranges] + port_ips[dhcp_interface_name][dhcp_interface_ip][port_name] = ranges + return port_ips diff --git a/src/sonic-dhcp-server/dhcp_server/dhcp_lease.py b/src/sonic-dhcp-server/dhcp_server/dhcp_lease.py new file mode 100644 index 0000000000..693a93d2c0 --- /dev/null +++ b/src/sonic-dhcp-server/dhcp_server/dhcp_lease.py @@ -0,0 +1,154 @@ +import ipaddress +import json +import signal +import syslog +import threading +import time +from abc import abstractmethod +from collections import deque +from datetime import datetime + +DHCP_SERVER_IPV4_LEASE = "DHCP_SERVER_IPV4_LEASE" +KEA_LEASE_FILE_PATH = "/tmp/kea-lease.csv" +DEFAULE_LEASE_UPDATE_INTERVAL = 2 # unit: sec + + +class LeaseManager(object): + def __init__(self, db_connector, kea_lease_file=KEA_LEASE_FILE_PATH): + self.lease_handlers = [KeaDhcp4LeaseHandler(db_connector, kea_lease_file)] + + def start(self): + """ + Register lease hanlder + """ + for handler in self.lease_handlers: + handler.register() + + +class LeaseHanlder(object): + def __init__(self, db_connector, lease_update_interval=DEFAULE_LEASE_UPDATE_INTERVAL): + self.db_connector = db_connector + self.lease_update_interval = lease_update_interval + self.last_update_time = None + self.lock = threading.Lock() + + @abstractmethod + def _read(self): + """ + Read lease file to get newest lease information + """ + raise NotImplementedError + + @abstractmethod + def register(self): + """ + Register callback function + """ + raise NotImplementedError + + def update_lease(self): + """ + Update lease table in STATE_DB + """ + last_update_time = self.last_update_time + curr_time = datetime.now() + # If the time since the last update is less than self.lease_update_interval, then wait for a + # self.lease_update_interval + if last_update_time is not None and (curr_time - last_update_time).seconds < self.lease_update_interval: + time.sleep(self.lease_update_interval) + if self.last_update_time != last_update_time: + # Means lease has been updated during sleep, no need to update this lease + return + if not self.lock.acquire(False): + return + new_lease = self._read() + # Store old lease key + old_lease_table = self.db_connector.get_state_db_table(DHCP_SERVER_IPV4_LEASE) + old_lease_key = set(old_lease_table.keys()) + + # 1.1 If start time equal to end time, means lease has been released + # 1.1.1 If current lease table has this old lease, delete it + # 1.1.2 Else skip + # 1.2 Else, means lease valid, save it. + for key, value in new_lease.items(): + if value["lease_start"] == value["lease_end"]: + if key in old_lease_key: + self.db_connector.state_db.delete("{}|{}".format(DHCP_SERVER_IPV4_LEASE, key)) + continue + new_key = "{}|{}".format(DHCP_SERVER_IPV4_LEASE, key) + for k, v in new_lease[key].items(): + self.db_connector.state_db.hset(new_key, k, v) + # Delete old lease not in new lease set + for key in old_lease_key: + if key not in new_lease.keys(): + # Delete entry + self.db_connector.state_db.delete("{}|{}".format(DHCP_SERVER_IPV4_LEASE, key)) + self.last_update_time = datetime.now() + self.lock.release() + + +class KeaDhcp4LeaseHandler(LeaseHanlder): + def __init__(self, db_connector, lease_file=KEA_LEASE_FILE_PATH): + LeaseHanlder.__init__(self, db_connector) + self.lease_file = lease_file + + def register(self): + """ + Register callback function of signal + """ + signal.signal(signal.SIGUSR1, self._update_lease) + + def _read(self): + # Read lease file generated by kea-dhcp4 + try: + with open(self.lease_file, "r", encoding="utf-8") as fb: + dq = deque(fb) + except FileNotFoundError as err: + syslog.syslog(syslog.LOG_ERR, "Cannot find lease file: {}".format(self.lease_file)) + raise err + + fdb_info = self._get_fdb_info() + new_lease = {} + # Get newest lease information of each client + while dq: + last_row = dq.pop() + splits = last_row.split(",") + # Skip header + if splits[0] == "address": + break + ip_str = splits[0] + mac_address = splits[1] + valid_lifetime = splits[3] + lease_end = splits[4] + + if mac_address not in fdb_info: + syslog.syslog(syslog.LOG_WARNING, "Cannot not find {} in fdb table".format(mac_address)) + continue + new_key = "{}|{}".format(fdb_info[mac_address], mac_address) + if new_key in new_lease: + continue + new_lease[new_key] = { + "lease_start": str(int(lease_end) - int(valid_lifetime)), + "lease_end": lease_end, + "ip": ip_str + } + return new_lease + + def _get_fdb_info(self): + """ + Get fdb information, indicate that mac address comes from which dhcp interface. + Returns: + Dict of fdb information, sample: + { + "aa:bb:cc:dd:ee:ff": "Vlan1000" + } + """ + fdb_table = self.db_connector.get_state_db_table("FDB_TABLE") + ret = {} + for key in fdb_table.keys(): + splits = key.split(":", 1) + ret[splits[1]] = splits[0] + return ret + + def _update_lease(self, signum, frame): + self.update_lease() diff --git a/src/sonic-dhcp-server/dhcp_server/dhcp_server_utils.py b/src/sonic-dhcp-server/dhcp_server/dhcp_server_utils.py new file mode 100644 index 0000000000..b045752bc6 --- /dev/null +++ b/src/sonic-dhcp-server/dhcp_server/dhcp_server_utils.py @@ -0,0 +1,99 @@ +from swsscommon import swsscommon + +DEFAULT_REDIS_HOST = "127.0.0.1" +DEFAULT_REDIS_PORT = 6379 + + +class DhcpDbConnector(object): + def __init__(self, redis_host=DEFAULT_REDIS_HOST, redis_port=DEFAULT_REDIS_PORT, redis_sock=None): + if redis_sock is not None: + self.redis_sock = redis_sock + self.config_db = swsscommon.DBConnector(swsscommon.CONFIG_DB, redis_sock, 0) + self.state_db = swsscommon.DBConnector(swsscommon.STATE_DB, redis_sock, 0) + else: + self.config_db = swsscommon.DBConnector(swsscommon.CONFIG_DB, redis_host, redis_port, 0) + self.state_db = swsscommon.DBConnector(swsscommon.STATE_DB, redis_host, redis_port, 0) + + def get_config_db_table(self, table_name): + """ + Get table from config_db. + Args: + table_name: Name of table want to get. + Return: + Table objects. + """ + return _parse_table_to_dict(swsscommon.Table(self.config_db, table_name)) + + def get_state_db_table(self, table_name): + """ + Get table from state_db. + Args: + table_name: Name of table want to get. + Return: + Table objects. + """ + return _parse_table_to_dict(swsscommon.Table(self.state_db, table_name)) + + +def get_entry(table, entry_name): + """ + Get dict entry from Table object. + Args: + table: Table object. + entry_name: Name of entry. + Returns: + Dict of entry, sample: + { + "customized_options": "option60,option223", + "gateway": "192.168.0.1", + "lease_time": "900", + "mode": "PORT", + "netmask": "255.255.255.0", + "state": "enabled" + } + """ + (_, entry) = table.get(entry_name) + return dict(entry) + + +def merge_intervals(intervals): + """ + Merge ip range intervals. + Args: + intervals: Ip ranges, may have overlaps, sample: + [ + [IPv4Address('192.168.0.2'), IPv4Address('192.168.0.5')], + [IPv4Address('192.168.0.3'), IPv4Address('192.168.0.6')], + [IPv4Address('192.168.0.10'), IPv4Address('192.168.0.10')] + ] + Returns: + Merged ip ranges, sample: + [ + [IPv4Address('192.168.0.2'), IPv4Address('192.168.0.6')], + [IPv4Address('192.168.0.10'), IPv4Address('192.168.0.10')] + ] + """ + intervals.sort(key=lambda x: x[0]) + ret = [] + for interval in intervals: + if len(ret) == 0 or interval[0] > ret[-1][-1]: + ret.append(interval) + else: + ret[-1][-1] = max(ret[-1][-1], interval[-1]) + return ret + + +def _parse_table_to_dict(table): + ret = {} + for key in table.getKeys(): + entry = get_entry(table, key) + new_entry = {} + + for field, value in entry.items(): + # if value of this field is list, field end with @, so cannot found by hget + if table.hget(key, field)[0]: + new_entry[field] = value + else: + new_entry[field] = value.split(",") + ret[key] = new_entry + return ret diff --git a/src/sonic-dhcp-server/dhcp_server/dhcpservd.py b/src/sonic-dhcp-server/dhcp_server/dhcpservd.py new file mode 100644 index 0000000000..a82db326c9 --- /dev/null +++ b/src/sonic-dhcp-server/dhcp_server/dhcpservd.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +import psutil +import signal +import time +from .dhcp_cfggen import DhcpServCfgGenerator +from .dhcp_lease import LeaseManager +from .dhcp_server_utils import DhcpDbConnector + +KEA_DHCP4_CONFIG = "/etc/kea/kea-dhcp4.conf" +KEA_DHCP4_PROC_NAME = "kea-dhcp4" +KEA_LEASE_FILE_PATH = "/tmp/kea-lease.csv" +REDIS_SOCK_PATH = "/var/run/redis/redis.sock" + + +class DhcpServd(object): + def __init__(self, dhcp_cfg_generator, db_connector, kea_dhcp4_config_path=KEA_DHCP4_CONFIG): + self.dhcp_cfg_generator = dhcp_cfg_generator + self.db_connector = db_connector + self.kea_dhcp4_config_path = kea_dhcp4_config_path + + def _notify_kea_dhcp4_proc(self): + """ + Send SIGHUP signal to kea-dhcp4 process + """ + for proc in psutil.process_iter(): + if KEA_DHCP4_PROC_NAME in proc.name(): + proc.send_signal(signal.SIGHUP) + break + + def dump_dhcp4_config(self): + """ + Generate kea-dhcp4 config file and dump it to config folder + """ + kea_dhcp4_config = self.dhcp_cfg_generator.generate() + with open(self.kea_dhcp4_config_path, "w") as write_file: + write_file.write(kea_dhcp4_config) + # After refresh kea-config, we need to SIGHUP kea-dhcp4 process to read new config + self._notify_kea_dhcp4_proc() + + def start(self): + self.dump_dhcp4_config() + lease_manager = LeaseManager(self.db_connector, KEA_LEASE_FILE_PATH) + lease_manager.start() + + # TODO Add config db subcribe to re-generate kea-dhcp4 config after config_db change. + + def wait(self): + while True: + time.sleep(5) + + +def main(): + dhcp_db_connector = DhcpDbConnector(redis_sock=REDIS_SOCK_PATH) + dhcp_cfg_generator = DhcpServCfgGenerator(dhcp_db_connector) + dhcpservd = DhcpServd(dhcp_cfg_generator, dhcp_db_connector) + dhcpservd.start() + dhcpservd.wait() + + +if __name__ == "__main__": + main() diff --git a/src/sonic-dhcp-server/setup.cfg b/src/sonic-dhcp-server/setup.cfg new file mode 100644 index 0000000000..a9cd7dd6a1 --- /dev/null +++ b/src/sonic-dhcp-server/setup.cfg @@ -0,0 +1,20 @@ +[aliases] +test=pytest + +[tool:pytest] +addopts = --durations=5 --cov +testpaths = tests + +[coverage:run] +branch = True +source = dhcp_server + +[coverage:report] +exclude_lines = + main() + if __name__ == .__main__.: +precision = 2 +show_missing = True +skip_covered = True +sort = Cover +fail_under = 80 diff --git a/src/sonic-dhcp-server/setup.py b/src/sonic-dhcp-server/setup.py new file mode 100644 index 0000000000..c81a915cef --- /dev/null +++ b/src/sonic-dhcp-server/setup.py @@ -0,0 +1,49 @@ +from setuptools import setup + +dependencies = [ + "psutil", + "coverage" +] + +test_deps = [ + "pytest" +] + +py_modules = [ + "dhcp_server_utils", + "dhcp_cfggen", + "dhcp_lease" +] + +setup( + name="sonic-dhcp-server", + install_requires=dependencies, + description="Module of SONiC built-in dhcp_server", + version="1.0", + url="https://github.com/Azure/sonic-buildimage", + tests_require=test_deps, + author="SONiC Team", + author_email="yaqiangzhu@microsoft.com", + setup_requires=[ + "pytest-runner", + "wheel", + ], + packages=[ + "dhcp_server" + ], + entry_points={ + "console_scripts": [ + "dhcpservd = dhcp_server.dhcpservd:main" + ] + }, + py_modules=py_modules, + classifiers=[ + "Intended Audience :: Developers", + "Operating System :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8" + ] +) diff --git a/src/sonic-dhcp-server/tests/common_utils.py b/src/sonic-dhcp-server/tests/common_utils.py new file mode 100644 index 0000000000..a9ebadd3a4 --- /dev/null +++ b/src/sonic-dhcp-server/tests/common_utils.py @@ -0,0 +1,12 @@ +import json + +MOCK_CONFIG_DB_PATH = "tests/test_data/mock_config_db.json" + + +class MockConfigDb(object): + def __init__(self, config_db_path=MOCK_CONFIG_DB_PATH): + with open(config_db_path, "r", encoding="utf8") as file: + self.config_db = json.load(file) + + def get_config_db_table(self, table_name): + return self.config_db.get(table_name, {}) diff --git a/src/sonic-dhcp-server/tests/conftest.py b/src/sonic-dhcp-server/tests/conftest.py new file mode 100644 index 0000000000..295cc543b4 --- /dev/null +++ b/src/sonic-dhcp-server/tests/conftest.py @@ -0,0 +1,35 @@ +import pytest +import dhcp_server.dhcp_server_utils as dhcp_server_utils +from unittest.mock import patch, PropertyMock +from dhcp_server.dhcp_cfggen import DhcpServCfgGenerator + + +@pytest.fixture(scope="function") +def mock_swsscommon_dbconnector_init(): + with patch.object(dhcp_server_utils.swsscommon.DBConnector, "__init__", return_value=None) as mock_dbconnector_init: + yield mock_dbconnector_init + + +@pytest.fixture(scope="function") +def mock_swsscommon_table_init(): + with patch.object(dhcp_server_utils.swsscommon.Table, "__init__", return_value=None) as mock_table_init: + yield mock_table_init + + +@pytest.fixture(scope="function") +def mock_get_render_template(): + with patch("dhcp_server.dhcp_cfggen.DhcpServCfgGenerator._get_render_template", return_value=None) as mock_template: + yield mock_template + + +@pytest.fixture +def mock_parse_port_map_alias(scope="function"): + with patch("dhcp_server.dhcp_cfggen.DhcpServCfgGenerator._parse_port_map_alias", return_value=None) as mock_map, \ + patch.object(DhcpServCfgGenerator, "port_alias_map", return_value={"Ethernet24": "etp7", "Ethernet28": "etp8"}, + new_callable=PropertyMock), \ + patch.object(DhcpServCfgGenerator, "lease_update_script_path", return_value="/etc/kea/lease_update.sh", + new_callable=PropertyMock), \ + patch.object(DhcpServCfgGenerator, "lease_path", return_value="/tmp/kea-lease.csv", new_callable=PropertyMock): + yield mock_map + + diff --git a/src/sonic-dhcp-server/tests/kea-dhcp4.conf.j2 b/src/sonic-dhcp-server/tests/kea-dhcp4.conf.j2 new file mode 100644 index 0000000000..27fd1a1d1f --- /dev/null +++ b/src/sonic-dhcp-server/tests/kea-dhcp4.conf.j2 @@ -0,0 +1,87 @@ +{%- set default_lease_time = 900 -%} +{ + "Dhcp4": { + "hooks-libraries": [ + { + "library": "/usr/local/lib/kea/hooks/libdhcp_run_script.so", + "parameters": { + "name": "{{ lease_update_script_path }}", + "sync": false + } + } + ], + "interfaces-config": { + "interfaces": [ + "eth0" + ] + }, + "control-socket": { + "socket-type": "unix", + "socket-name": "/run/kea/kea4-ctrl-socket" + }, + "lease-database": { + "type": "memfile", + "persist": true, + "name": "{{ lease_path }}", + "lfc-interval": 3600 + }, + "subnet4": [ +{%- set add_subnet_preceding_comma = { 'flag': False } %} +{%- for subnet_info in subnets %} + {%- if add_subnet_preceding_comma.flag -%},{%- endif -%} + {%- set _dummy = add_subnet_preceding_comma.update({'flag': True}) %} + { + "subnet": "{{ subnet_info["subnet"] }}", + "pools": [ + {%- set add_pool_preceding_comma = { 'flag': False } %} + {%- for pool in subnet_info["pools"] %} + {%- if add_pool_preceding_comma.flag -%},{%- endif -%} + {%- set _dummy = add_pool_preceding_comma.update({'flag': True}) %} + { + "pool": "{{ pool["range"] }}", + "client-class": "{{ pool["client_class"] }}" + } + {%- endfor%} + ], + "option-data": [ + { + "name": "routers", + "data": "{{ subnet_info["gateway"] if "gateway" in subnet_info else subnet_info["server_id"] }}" + }, + { + "name": "dhcp-server-identifier", + "data": "{{ subnet_info["server_id"] }}" + } + ], + "valid-lifetime": {{ subnet_info["lease_time"] if "lease_time" in subnet_info else default_lease_time }}, + "reservations": [] + } +{%- endfor %} + ], + "loggers": [ + { + "name": "kea-dhcp4", + "output_options": [ + { + "output": "/var/log/kea-dhcp.log", + "pattern": "%-5p %m\n" + } + ], + "severity": "INFO", + "debuglevel": 0 + } + ]{%- if client_classes -%}, + "client-classes": [ + {%- set add_preceding_comma = { 'flag': False } %} + {%- for class in client_classes %} + {%- if add_preceding_comma.flag -%},{%- endif -%} + {%- set _dummy = add_preceding_comma.update({'flag': True}) %} + { + "name": "{{ class["name"] }}", + "test": "{{ class["condition"] }}" + } + {%- endfor %} + ] + {%- endif %} + } +} diff --git a/src/sonic-dhcp-server/tests/test_data/kea-dhcp4.conf.j2 b/src/sonic-dhcp-server/tests/test_data/kea-dhcp4.conf.j2 new file mode 100644 index 0000000000..27fd1a1d1f --- /dev/null +++ b/src/sonic-dhcp-server/tests/test_data/kea-dhcp4.conf.j2 @@ -0,0 +1,87 @@ +{%- set default_lease_time = 900 -%} +{ + "Dhcp4": { + "hooks-libraries": [ + { + "library": "/usr/local/lib/kea/hooks/libdhcp_run_script.so", + "parameters": { + "name": "{{ lease_update_script_path }}", + "sync": false + } + } + ], + "interfaces-config": { + "interfaces": [ + "eth0" + ] + }, + "control-socket": { + "socket-type": "unix", + "socket-name": "/run/kea/kea4-ctrl-socket" + }, + "lease-database": { + "type": "memfile", + "persist": true, + "name": "{{ lease_path }}", + "lfc-interval": 3600 + }, + "subnet4": [ +{%- set add_subnet_preceding_comma = { 'flag': False } %} +{%- for subnet_info in subnets %} + {%- if add_subnet_preceding_comma.flag -%},{%- endif -%} + {%- set _dummy = add_subnet_preceding_comma.update({'flag': True}) %} + { + "subnet": "{{ subnet_info["subnet"] }}", + "pools": [ + {%- set add_pool_preceding_comma = { 'flag': False } %} + {%- for pool in subnet_info["pools"] %} + {%- if add_pool_preceding_comma.flag -%},{%- endif -%} + {%- set _dummy = add_pool_preceding_comma.update({'flag': True}) %} + { + "pool": "{{ pool["range"] }}", + "client-class": "{{ pool["client_class"] }}" + } + {%- endfor%} + ], + "option-data": [ + { + "name": "routers", + "data": "{{ subnet_info["gateway"] if "gateway" in subnet_info else subnet_info["server_id"] }}" + }, + { + "name": "dhcp-server-identifier", + "data": "{{ subnet_info["server_id"] }}" + } + ], + "valid-lifetime": {{ subnet_info["lease_time"] if "lease_time" in subnet_info else default_lease_time }}, + "reservations": [] + } +{%- endfor %} + ], + "loggers": [ + { + "name": "kea-dhcp4", + "output_options": [ + { + "output": "/var/log/kea-dhcp.log", + "pattern": "%-5p %m\n" + } + ], + "severity": "INFO", + "debuglevel": 0 + } + ]{%- if client_classes -%}, + "client-classes": [ + {%- set add_preceding_comma = { 'flag': False } %} + {%- for class in client_classes %} + {%- if add_preceding_comma.flag -%},{%- endif -%} + {%- set _dummy = add_preceding_comma.update({'flag': True}) %} + { + "name": "{{ class["name"] }}", + "test": "{{ class["condition"] }}" + } + {%- endfor %} + ] + {%- endif %} + } +} diff --git a/src/sonic-dhcp-server/tests/test_data/kea-lease.csv b/src/sonic-dhcp-server/tests/test_data/kea-lease.csv new file mode 100644 index 0000000000..85976d8cb5 --- /dev/null +++ b/src/sonic-dhcp-server/tests/test_data/kea-lease.csv @@ -0,0 +1,9 @@ +address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id +192.168.0.2,10:70:fd:b6:13:00,,3600,1694000905,1,0,0,7626dced293e,0,,0 +192.168.0.131,10:70:fd:b6:13:17,,3600,1694000909,1,0,0,7626dced293e,0,,1 +192.168.0.131,10:70:fd:b6:13:17,,0,1693997309,1,0,0,7626dced293e,0,,1 +192.168.0.131,10:70:fd:b6:13:17,,0,1693997309,1,0,0,,2,,1 +192.168.0.131,10:70:fd:b6:13:17,,3600,1694000915,1,0,0,7626dced293e,0,,1 +192.168.0.2,10:70:fd:b6:13:00,,0,1693997305,1,0,0,7626dced293e,0,,0 +193.168.2.2,10:70:fd:b6:13:15,,3600,1693999305,1,0,0,7626dced293e,0,,0 +193.168.2.3,10:70:fd:b6:13:20,,3600,1693999305,1,0,0,7626dced293e,0,,0 \ No newline at end of file diff --git a/src/sonic-dhcp-server/tests/test_data/mock_config_db.json b/src/sonic-dhcp-server/tests/test_data/mock_config_db.json new file mode 100644 index 0000000000..0e6f4387f4 --- /dev/null +++ b/src/sonic-dhcp-server/tests/test_data/mock_config_db.json @@ -0,0 +1,159 @@ +{ + "DEVICE_METADATA": { + "localhost": { + "hostname": "sonic-host" + } + }, + "VLAN_INTERFACE": { + "Vlan1000|192.168.0.1/21": { + "NULL": "NULL" + }, + "Vlan1000": { + "NULL": "NULL" + }, + "Vlan1000|fc02:1000::1/64": { + "NULL": "NULL" + }, + "Vlan2000|192.168.1.1/21": { + "NULL": "NULL" + }, + "Vlan2000|192.168.2.1/21": { + "NULL": "NULL" + }, + "Vlan2000": { + "NULL": "NULL" + } + }, + "VLAN_MEMBER": { + "Vlan1000|Ethernet24": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet28": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet40": { + "tagging_mode": "untagged" + } + }, + "DHCP_SERVER_IPV4": { + "Vlan1000": { + "customized_options": [ + "option60", + "option223" + ], + "gateway": "192.168.0.1", + "lease_time": "900", + "mode": "PORT", + "netmask": "255.255.255.0", + "state": "enabled" + }, + "Vlan2000": { + "customized_options": [ + "option60" + ], + "gateway": "192.168.1.1", + "lease_time": "900", + "mode": "PORT", + "netmask": "255.255.255.0", + "state": "disabled" + }, + "Vlan3000": { + "customized_options": [ + "option60" + ], + "gateway": "192.168.2.1", + "lease_time": "900", + "mode": "STATIC", + "netmask": "255.255.255.0", + "state": "enabled" + }, + "Vlan4000": { + "customized_options": [ + "option60", + "option223" + ], + "gateway": "192.168.3.1", + "lease_time": "900", + "mode": "PORT", + "netmask": "255.255.255.0", + "state": "enabled" + } + }, + "DHCP_SERVER_IPV4_CUSTOMIZED_OPTIONS": { + "option223": { + "id": "223", + "type": "text", + "value": "dummy_value" + }, + "option60": { + "id": "60", + "type": "text", + "value": "dummy_value" + } + }, + "DHCP_SERVER_IPV4_RANGE": { + "range3": { + "range": [ + "192.168.0.10", + "192.168.0.10" + ] + }, + "range2": { + "range": [ + "192.168.0.6", + "192.168.0.3" + ] + }, + "range1": { + "range": [ + "192.168.0.2", + "192.168.0.5" + ] + }, + "range4": { + "range": [ + "192.168.0.1" + ] + }, + "range0": { + "range": [ + "192.168.8.2", + "192.168.8.3" + ] + } + }, + "DHCP_SERVER_IPV4_PORT": { + "Vlan1000|Ethernet24": { + "ips": [ + "192.168.0.7" + ] + }, + "Vlan1000|Ethernet28": { + "ranges": [ + "range0", + "range1", + "range2", + "range3", + "range6" + ] + }, + "Vlan1000|Ethernet32": { + "ips": [ + "192.168.0.8" + ], + "ranges": [ + "range0" + ] + }, + "Vlan1000|Ethernet36": { + "ips": [ + "192.168.0.9" + ] + }, + "Vlan1000|Ethernet40": { + "ips": [ + "192.168.0.10" + ] + } + } +} diff --git a/src/sonic-dhcp-server/tests/test_data/mock_config_db_without_port_config.json b/src/sonic-dhcp-server/tests/test_data/mock_config_db_without_port_config.json new file mode 100644 index 0000000000..5adc02679b --- /dev/null +++ b/src/sonic-dhcp-server/tests/test_data/mock_config_db_without_port_config.json @@ -0,0 +1,149 @@ +{ + "DEVICE_METADATA": { + "localhost": { + "hostname": "sonic-host" + } + }, + "VLAN_INTERFACE": { + "Vlan1000|192.168.0.1/21": { + "NULL": "NULL" + }, + "Vlan1000": { + "NULL": "NULL" + }, + "Vlan1000|fc02:1000::1/64": { + "NULL": "NULL" + } + }, + "VLAN_MEMBER": { + "Vlan1000|Ethernet68": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet72": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet8": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet80": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet96": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet48": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet32": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet4": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet24": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet40": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet52": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet28": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet44": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet60": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet16": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet12": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet84": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet36": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet64": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet92": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet56": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet20": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet88": { + "tagging_mode": "untagged" + }, + "Vlan1000|Ethernet76": { + "tagging_mode": "untagged" + } + }, + "DHCP_SERVER_IPV4": { + "Vlan1000": { + "customized_options": [ + "option60", + "option223" + ], + "gateway": "192.168.0.1", + "lease_time": "900", + "mode": "PORT", + "netmask": "255.255.255.0", + "state": "enabled" + }, + "Vlan2000": { + "customized_options": [ + "option60" + ], + "gateway": "192.168.0.1", + "lease_time": "900", + "mode": "PORT", + "netmask": "255.255.255.0", + "state": "disabled" + } + }, + "DHCP_SERVER_IPV4_CUSTOMIZED_OPTIONS": { + "option223": { + "id": "223", + "type": "text", + "value": "dummy_value" + }, + "option60": { + "id": "60", + "type": "text", + "value": "dummy_value" + } + }, + "DHCP_SERVER_IPV4_RANGE": { + "range3": { + "range": [ + "192.168.0.10", + "192.168.0.10" + ] + }, + "range2": { + "range": [ + "192.168.0.6", + "192.168.0.3" + ] + }, + "range1": { + "range": [ + "192.168.0.2", + "192.168.0.5" + ] + } + }, + "DHCP_SERVER_IPV4_PORT": { + } +} diff --git a/src/sonic-dhcp-server/tests/test_data/port-name-alias-map.txt b/src/sonic-dhcp-server/tests/test_data/port-name-alias-map.txt new file mode 100644 index 0000000000..edebd17db9 --- /dev/null +++ b/src/sonic-dhcp-server/tests/test_data/port-name-alias-map.txt @@ -0,0 +1,3 @@ +Ethernet24 etp7 +Ethernet28 etp8 +Ethernet32 \ No newline at end of file diff --git a/src/sonic-dhcp-server/tests/test_dhcp_cfggen.py b/src/sonic-dhcp-server/tests/test_dhcp_cfggen.py new file mode 100644 index 0000000000..25b46f8544 --- /dev/null +++ b/src/sonic-dhcp-server/tests/test_dhcp_cfggen.py @@ -0,0 +1,265 @@ +import copy +import ipaddress +import json +import pytest +from common_utils import MockConfigDb +from dhcp_server.dhcp_server_utils import DhcpDbConnector +from dhcp_server.dhcp_cfggen import DhcpServCfgGenerator +from unittest.mock import patch, MagicMock + +expected_dhcp_config = { + "Dhcp4": { + "hooks-libraries": [ + { + "library": "/usr/local/lib/kea/hooks/libdhcp_run_script.so", + "parameters": { + "name": "/etc/kea/lease_update.sh", + "sync": False + } + } + ], + "interfaces-config": { + "interfaces": [ + "eth0" + ] + }, + "control-socket": { + "socket-type": "unix", + "socket-name": "/run/kea/kea4-ctrl-socket" + }, + "lease-database": { + "type": "memfile", + "persist": True, + "name": "/tmp/kea-lease.csv", + "lfc-interval": 3600 + }, + "subnet4": [ + { + "subnet": "192.168.0.0/21", + "pools": [ + { + "pool": "192.168.0.2 - 192.168.0.6", + "client-class": "sonic-host:etp8" + }, + { + "pool": "192.168.0.10 - 192.168.0.10", + "client-class": "sonic-host:etp8" + }, + { + "pool": "192.168.0.7 - 192.168.0.7", + "client-class": "sonic-host:etp7" + } + ], + "option-data": [ + { + "name": "routers", + "data": "192.168.0.1" + }, + { + "name": "dhcp-server-identifier", + "data": "192.168.0.1" + } + ], + "valid-lifetime": 900, + "reservations": [] + } + ], + "loggers": [ + { + "name": "kea-dhcp4", + "output_options": [ + { + "output": "/var/log/kea-dhcp.log", + "pattern": "%-5p %m\n" + } + ], + "severity": "INFO", + "debuglevel": 0 + } + ], + "client-classes": [ + { + "name": "sonic-host:etp8", + "test": "substring(relay4[1].hex, -15, 15) == 'sonic-host:etp8'" + }, + { + "name": "sonic-host:etp7", + "test": "substring(relay4[1].hex, -15, 15) == 'sonic-host:etp7'" + } + ] + } +} +expected_dhcp_config_without_port_config = { + "Dhcp4": { + "hooks-libraries": [ + { + "library": "/usr/local/lib/kea/hooks/libdhcp_run_script.so", + "parameters": { + "name": "/etc/kea/lease_update.sh", + "sync": False + } + } + ], + "interfaces-config": { + "interfaces": [ + "eth0" + ] + }, + "control-socket": { + "socket-type": "unix", + "socket-name": "/run/kea/kea4-ctrl-socket" + }, + "lease-database": { + "type": "memfile", + "persist": True, + "name": "/tmp/kea-lease.csv", + "lfc-interval": 3600 + }, + "subnet4": [ + ], + "loggers": [ + { + "name": "kea-dhcp4", + "output_options": [ + { + "output": "/var/log/kea-dhcp.log", + "pattern": "%-5p %m\n" + } + ], + "severity": "INFO", + "debuglevel": 0 + } + ] + } +} +expected_parsed_range = { + "range2": [ipaddress.IPv4Address("192.168.0.3"), ipaddress.IPv4Address("192.168.0.6")], + "range3": [ipaddress.IPv4Address("192.168.0.10"), ipaddress.IPv4Address("192.168.0.10")], + "range1": [ipaddress.IPv4Address("192.168.0.2"), ipaddress.IPv4Address("192.168.0.5")], + "range0": [ipaddress.IPv4Address("192.168.8.2"), ipaddress.IPv4Address("192.168.8.3")] +} +expected_vlan_ipv4_interface = { + "Vlan1000": [{ + "ip": "192.168.0.1/21", + "network": ipaddress.ip_network("192.168.0.1/21", strict=False) + }], + "Vlan2000": [ + { + "ip": "192.168.1.1/21", + "network": ipaddress.ip_network("192.168.1.1/21", strict=False) + }, + { + "ip": "192.168.2.1/21", + "network": ipaddress.ip_network("192.168.2.1/21", strict=False) + } + ] +} +expected_parsed_port = { + "Vlan1000": { + "192.168.0.1/21": { + "etp8": [["192.168.0.2", "192.168.0.6"], ["192.168.0.10", "192.168.0.10"]], + "etp7": [["192.168.0.7", "192.168.0.7"]] + } + } +} +tested_parsed_port = { + "Vlan1000": { + "192.168.0.1/21": { + "etp8": [["192.168.0.2", "192.168.0.6"], ["192.168.0.10", "192.168.0.10"]], + "etp7": [["192.168.0.7", "192.168.0.7"]], + "etp9": [] + } + } +} +expected_render_obj = { + "subnets": [{ + "subnet": "192.168.0.0/21", + "pools": [{"range": "192.168.0.2 - 192.168.0.6", "client_class": "sonic-host:etp8"}, + {"range": "192.168.0.10 - 192.168.0.10", "client_class": "sonic-host:etp8"}, + {"range": "192.168.0.7 - 192.168.0.7", "client_class": "sonic-host:etp7"}], + "gateway": "192.168.0.1", "server_id": "192.168.0.1", "lease_time": "900" + }], + "client_classes": [ + {"name": "sonic-host:etp8", "condition": "substring(relay4[1].hex, -15, 15) == 'sonic-host:etp8'"}, + {"name": "sonic-host:etp7", "condition": "substring(relay4[1].hex, -15, 15) == 'sonic-host:etp7'"} + ], + "lease_update_script_path": "/etc/kea/lease_update.sh", + "lease_path": "/tmp/kea-lease.csv" +} + + +def test_parse_port_alias(mock_swsscommon_dbconnector_init, mock_get_render_template): + dhcp_db_connector = DhcpDbConnector() + dhcp_cfg_generator = DhcpServCfgGenerator(dhcp_db_connector, + port_map_path="tests/test_data/port-name-alias-map.txt") + assert dhcp_cfg_generator.port_alias_map == {'Ethernet24': 'etp7', 'Ethernet28': 'etp8'} + + +@pytest.mark.parametrize("is_success", [True, False]) +def test_parse_hostname(is_success, mock_swsscommon_dbconnector_init, mock_parse_port_map_alias, + mock_get_render_template): + mock_config_db = MockConfigDb(config_db_path="tests/test_data/mock_config_db.json") + dhcp_db_connector = DhcpDbConnector() + dhcp_cfg_generator = DhcpServCfgGenerator(dhcp_db_connector) + device_metadata = mock_config_db.config_db.get("DEVICE_METADATA") if is_success else {} + try: + hostname = dhcp_cfg_generator._parse_hostname(device_metadata) + assert hostname == "sonic-host" + except Exception as err: + assert str(err) == "Cannot get hostname" + + +def test_parse_range(mock_swsscommon_dbconnector_init, mock_parse_port_map_alias, mock_get_render_template): + mock_config_db = MockConfigDb(config_db_path="tests/test_data/mock_config_db.json") + dhcp_db_connector = DhcpDbConnector() + dhcp_cfg_generator = DhcpServCfgGenerator(dhcp_db_connector) + parse_result = dhcp_cfg_generator._parse_range(mock_config_db.config_db.get("DHCP_SERVER_IPV4_RANGE")) + assert parse_result == expected_parsed_range + + +def test_parse_vlan(mock_swsscommon_dbconnector_init, mock_parse_port_map_alias, mock_get_render_template): + mock_config_db = MockConfigDb(config_db_path="tests/test_data/mock_config_db.json") + dhcp_db_connector = DhcpDbConnector() + dhcp_cfg_generator = DhcpServCfgGenerator(dhcp_db_connector) + vlan_interfaces, vlan_members = dhcp_cfg_generator._parse_vlan(mock_config_db.config_db.get("VLAN_INTERFACE"), + mock_config_db.config_db.get("VLAN_MEMBER")) + assert vlan_interfaces == expected_vlan_ipv4_interface + assert list(vlan_members) == ["Vlan1000|Ethernet24", "Vlan1000|Ethernet28", "Vlan1000|Ethernet40"] + + +@pytest.mark.parametrize("test_config_db", ["mock_config_db.json", "mock_config_db_without_port_config.json"]) +def test_parse_port(test_config_db, mock_swsscommon_dbconnector_init, mock_get_render_template, + mock_parse_port_map_alias): + mock_config_db = MockConfigDb(config_db_path="tests/test_data/{}".format(test_config_db)) + dhcp_db_connector = DhcpDbConnector() + dhcp_cfg_generator = DhcpServCfgGenerator(dhcp_db_connector) + tested_vlan_interfaces = expected_vlan_ipv4_interface + tested_ranges = expected_parsed_range + ipv4_port = mock_config_db.config_db.get("DHCP_SERVER_IPV4_PORT") + vlan_members = mock_config_db.config_db.get("VLAN_MEMBER").keys() + parse_result = dhcp_cfg_generator._parse_port(ipv4_port, tested_vlan_interfaces, vlan_members, tested_ranges) + assert parse_result == (expected_parsed_port if test_config_db == "mock_config_db.json" else {}) + + +def test_construct_obj_for_template(mock_swsscommon_dbconnector_init, mock_parse_port_map_alias, + mock_get_render_template): + mock_config_db = MockConfigDb(config_db_path="tests/test_data/mock_config_db.json") + dhcp_db_connector = DhcpDbConnector() + dhcp_cfg_generator = DhcpServCfgGenerator(dhcp_db_connector) + tested_hostname = "sonic-host" + render_obj = dhcp_cfg_generator._construct_obj_for_template(mock_config_db.config_db.get("DHCP_SERVER_IPV4"), + tested_parsed_port, tested_hostname) + assert render_obj == expected_render_obj + + +@pytest.mark.parametrize("with_port_config", [True, False]) +def test_render_config(mock_swsscommon_dbconnector_init, mock_parse_port_map_alias, with_port_config): + dhcp_db_connector = DhcpDbConnector() + dhcp_cfg_generator = DhcpServCfgGenerator(dhcp_db_connector, + kea_conf_template_path="tests/test_data/kea-dhcp4.conf.j2") + render_obj = copy.deepcopy(expected_render_obj) + if not with_port_config: + render_obj["client_classes"] = [] + render_obj["subnets"] = [] + config = dhcp_cfg_generator._render_config(render_obj) + assert json.loads(config) == expected_dhcp_config if with_port_config else expected_dhcp_config_without_port_config diff --git a/src/sonic-dhcp-server/tests/test_dhcp_lease.py b/src/sonic-dhcp-server/tests/test_dhcp_lease.py new file mode 100644 index 0000000000..08a93e2c4c --- /dev/null +++ b/src/sonic-dhcp-server/tests/test_dhcp_lease.py @@ -0,0 +1,103 @@ +from dhcp_server.dhcp_server_utils import DhcpDbConnector +from dhcp_server.dhcp_lease import KeaDhcp4LeaseHandler, LeaseHanlder +from swsscommon import swsscommon +from unittest.mock import patch, call, MagicMock + +expected_lease = { + "Vlan1000|10:70:fd:b6:13:00": { + "lease_start": "1693997305", + "lease_end": "1693997305", + "ip": "192.168.0.2" + }, + "Vlan1000|10:70:fd:b6:13:17": { + "lease_start": "1693997315", + "lease_end": "1694000915", + "ip": "192.168.0.131" + }, + "Vlan2000|10:70:fd:b6:13:15": { + "lease_start": "1693995705", + "lease_end": "1693999305", + "ip": "193.168.2.2" + } +} +expected_fdb_info = { + "10:70:fd:b6:13:00": "Vlan1000", + "10:70:fd:b6:13:15": "Vlan2000", + "10:70:fd:b6:13:17": "Vlan1000", +} + + +def test_read_kea_lease_with_file_not_found(mock_swsscommon_dbconnector_init): + db_connector = DhcpDbConnector() + kea_lease_handler = KeaDhcp4LeaseHandler(db_connector) + try: + kea_lease_handler._read() + except FileNotFoundError: + pass + + +def test_read_kea_lease(mock_swsscommon_dbconnector_init): + tested_fdb_info = expected_fdb_info + with patch.object(KeaDhcp4LeaseHandler, "_get_fdb_info", return_value=tested_fdb_info): + db_connector = DhcpDbConnector() + kea_lease_handler = KeaDhcp4LeaseHandler(db_connector, lease_file="tests/test_data/kea-lease.csv") + # Verify whether lease information read is as expected + lease = kea_lease_handler._read() + print(lease) + print(expected_lease) + assert lease == expected_lease + + +def test_get_fdb_info(mock_swsscommon_dbconnector_init): + mock_fdb_table = { + "Vlan2000:10:70:fd:b6:13:15": {"port": "Ethernet31", "type": "dynamic"}, + "Vlan1000:10:70:fd:b6:13:00": {"port": "Ethernet32", "type": "dynamic"}, + "Vlan1000:10:70:fd:b6:13:17": {"port": "Ethernet32", "type": "dynamic"} + } + with patch("dhcp_server.dhcp_server_utils.DhcpDbConnector.get_state_db_table", return_value=mock_fdb_table): + db_connector = DhcpDbConnector() + kea_lease_handler = KeaDhcp4LeaseHandler(db_connector, lease_file="tests/test_data/kea-lease.csv") + # Verify whether lease information read is as expected + fdb_info = kea_lease_handler._get_fdb_info() + assert fdb_info == expected_fdb_info + + +def test_update_kea_lease(mock_swsscommon_dbconnector_init, mock_swsscommon_table_init): + tested_lease = expected_lease + with patch.object(swsscommon.Table, "getKeys"), \ + patch.object(swsscommon.DBConnector, "hset") as mock_hset, \ + patch.object(KeaDhcp4LeaseHandler, "_read", MagicMock(return_value=tested_lease)), \ + patch.object(DhcpDbConnector, "get_state_db_table", + return_value={"Vlan1000|aa:bb:cc:dd:ee:ff": {}, "Vlan1000|10:70:fd:b6:13:00": {}}), \ + patch.object(swsscommon.DBConnector, "delete") as mock_delete, \ + patch("time.sleep", return_value=None) as mock_sleep: + db_connector = DhcpDbConnector() + kea_lease_handler = KeaDhcp4LeaseHandler(db_connector) + kea_lease_handler.update_lease() + # Verify that old key was deleted + mock_delete.assert_has_calls([ + call("DHCP_SERVER_IPV4_LEASE|Vlan1000|10:70:fd:b6:13:00"), + call("DHCP_SERVER_IPV4_LEASE|Vlan1000|aa:bb:cc:dd:ee:ff") + ]) + # Verify that lease has been updated, to be noted that lease for "192.168.0.2" didn't been updated because + # lease_start equals to lease_end + mock_hset.assert_has_calls([ + call('DHCP_SERVER_IPV4_LEASE|Vlan1000|10:70:fd:b6:13:17', 'lease_start', '1693997315'), + call('DHCP_SERVER_IPV4_LEASE|Vlan1000|10:70:fd:b6:13:17', 'lease_end', '1694000915'), + call('DHCP_SERVER_IPV4_LEASE|Vlan1000|10:70:fd:b6:13:17', 'ip', '192.168.0.131') + ]) + kea_lease_handler.update_lease() + mock_sleep.assert_called_once_with(2) + + +def test_no_implement(mock_swsscommon_dbconnector_init): + db_connector = DhcpDbConnector() + lease_handler = LeaseHanlder(db_connector) + try: + lease_handler._read() + except NotImplementedError: + pass + try: + lease_handler.register() + except NotImplementedError: + pass diff --git a/src/sonic-dhcp-server/tests/test_dhcp_server_utils.py b/src/sonic-dhcp-server/tests/test_dhcp_server_utils.py new file mode 100644 index 0000000000..7b7857265a --- /dev/null +++ b/src/sonic-dhcp-server/tests/test_dhcp_server_utils.py @@ -0,0 +1,84 @@ +import dhcp_server.dhcp_server_utils as dhcp_server_utils +import ipaddress +import pytest +from swsscommon import swsscommon +from unittest.mock import patch, call + +interval_test_data = { + "ordered_with_overlap": { + "intervals": [["192.168.0.2", "192.168.0.5"], ["192.168.0.3", "192.168.0.6"], ["192.168.0.10", "192.168.0.10"]], + "expected_res": [["192.168.0.2", "192.168.0.6"], ["192.168.0.10", "192.168.0.10"]] + }, + "not_order_with_overlap": { + "intervals": [["192.168.0.3", "192.168.0.6"], ["192.168.0.2", "192.168.0.5"], ["192.168.0.10", "192.168.0.10"]], + "expected_res": [["192.168.0.2", "192.168.0.6"], ["192.168.0.10", "192.168.0.10"]] + }, + "ordered_without_overlap": { + "intervals": [["192.168.0.2", "192.168.0.5"], ["192.168.0.10", "192.168.0.10"]], + "expected_res": [["192.168.0.2", "192.168.0.5"], ["192.168.0.10", "192.168.0.10"]] + }, + "not_ordered_without_overlap": { + "intervals": [["192.168.0.10", "192.168.0.10"], ["192.168.0.2", "192.168.0.5"]], + "expected_res": [["192.168.0.2", "192.168.0.5"], ["192.168.0.10", "192.168.0.10"]] + }, + "single_interval": { + "intervals": [["192.168.0.10", "192.168.0.10"]], + "expected_res": [["192.168.0.10", "192.168.0.10"]] + } +} + + +def test_construct_without_sock(mock_swsscommon_dbconnector_init): + dhcp_server_utils.DhcpDbConnector() + mock_swsscommon_dbconnector_init.assert_has_calls([ + call(swsscommon.CONFIG_DB, "127.0.0.1", 6379, 0), + call(swsscommon.STATE_DB, "127.0.0.1", 6379, 0) + ]) + + +def test_construct_sock(mock_swsscommon_dbconnector_init): + redis_sock = "/var/run/redis/redis.sock" + dhcp_db_connector = dhcp_server_utils.DhcpDbConnector(redis_sock=redis_sock) + assert dhcp_db_connector.redis_sock == redis_sock + + mock_swsscommon_dbconnector_init.assert_has_calls([ + call(swsscommon.CONFIG_DB, redis_sock, 0), + call(swsscommon.STATE_DB, redis_sock, 0) + ]) + + +def test_get_config_db_table(mock_swsscommon_dbconnector_init, mock_swsscommon_table_init): + dhcp_db_connector = dhcp_server_utils.DhcpDbConnector() + with patch.object(swsscommon.Table, "getKeys", return_value=["key1", "key2"]) as mock_get_keys, \ + patch.object(dhcp_server_utils, "get_entry", return_value={"list": "1,2", "value": "3,4"}), \ + patch.object(swsscommon.Table, "hget", side_effect=mock_hget): + ret = dhcp_db_connector.get_config_db_table("VLAN") + mock_swsscommon_table_init.assert_called_once_with(dhcp_db_connector.config_db, "VLAN") + print(ret) + mock_get_keys.assert_called_once_with() + print(ret) + assert ret == { + "key1": {"list": ["1", "2"], "value": "3,4"}, + "key2": {"list": ["1", "2"], "value": "3,4"} + } + + +@pytest.mark.parametrize("test_type", interval_test_data.keys()) +def test_merge_intervals(test_type): + intervals = convert_ip_address_intervals(interval_test_data[test_type]["intervals"]) + expected_res = convert_ip_address_intervals(interval_test_data[test_type]["expected_res"]) + assert dhcp_server_utils.merge_intervals(intervals) == expected_res + + +def mock_hget(_, field): + if field == "list": + return False, "" + else: + return True, "" + + +def convert_ip_address_intervals(intervals): + ret = [] + for interval in intervals: + ret.append([ipaddress.ip_address(interval[0]), ipaddress.ip_address(interval[1])]) + return ret diff --git a/src/sonic-dhcp-server/tests/test_dhcpservd.py b/src/sonic-dhcp-server/tests/test_dhcpservd.py new file mode 100644 index 0000000000..08e3253a6b --- /dev/null +++ b/src/sonic-dhcp-server/tests/test_dhcpservd.py @@ -0,0 +1,60 @@ +import pytest +import psutil +import signal +from dhcp_server.dhcp_server_utils import DhcpDbConnector +from dhcp_server.dhcp_cfggen import DhcpServCfgGenerator +from dhcp_server.dhcpservd import DhcpServd +from unittest.mock import patch, call, MagicMock + + +def test_dump_dhcp4_config(mock_swsscommon_dbconnector_init): + with patch("dhcp_server.dhcp_cfggen.DhcpServCfgGenerator.generate", return_value="dummy_config") as mock_generate, \ + patch("dhcp_server.dhcpservd.DhcpServd._notify_kea_dhcp4_proc", MagicMock()) as mock_notify_kea_dhcp4_proc: + dhcp_db_connector = DhcpDbConnector() + dhcp_cfg_generator = DhcpServCfgGenerator(dhcp_db_connector, + port_map_path="tests/test_data/port-name-alias-map.txt", + kea_conf_template_path="tests/test_data/kea-dhcp4.conf.j2") + dhcpservd = DhcpServd(dhcp_cfg_generator, dhcp_db_connector, kea_dhcp4_config_path="/tmp/kea-dhcp4.conf") + dhcpservd.dump_dhcp4_config() + # Verfiy whether generate() func of dhcp_cfggen is called + mock_generate.assert_called_once_with() + # Verify whether notify func of dhcpservd is called, which is expected to call after new config generated + mock_notify_kea_dhcp4_proc.assert_called_once_with() + + +@pytest.mark.parametrize("process_list", [["proc1", "proc2", "kea-dhcp4"], ["proc1", "proc2"]]) +def test_notify_kea_dhcp4_proc(process_list, mock_swsscommon_dbconnector_init, mock_get_render_template, + mock_parse_port_map_alias): + proc_list = [MockProc(process_name) for process_name in process_list] + with patch.object(psutil, "process_iter", return_value=proc_list), \ + patch.object(MockProc, "send_signal", MagicMock()) as mock_send_signal: + dhcp_db_connector = DhcpDbConnector() + dhcp_cfg_generator = DhcpServCfgGenerator(dhcp_db_connector) + dhcpservd = DhcpServd(dhcp_cfg_generator, dhcp_db_connector) + dhcpservd._notify_kea_dhcp4_proc() + if "kea-dhcp4" in process_list: + mock_send_signal.assert_has_calls([ + call(signal.SIGHUP) + ]) + else: + mock_send_signal.assert_not_called() + + +def test_start(mock_swsscommon_dbconnector_init, mock_parse_port_map_alias, mock_get_render_template): + with patch.object(DhcpServd, "dump_dhcp4_config") as mock_dump: + dhcp_db_connector = DhcpDbConnector() + dhcp_cfg_generator = DhcpServCfgGenerator(dhcp_db_connector) + dhcpservd = DhcpServd(dhcp_cfg_generator, dhcp_db_connector) + dhcpservd.start() + mock_dump.assert_called_once_with() + + +class MockProc(object): + def __init__(self, name): + self.proc_name = name + + def name(self): + return self.proc_name + + def send_signal(self, sig_num): + pass