diff --git a/.github/workflows/trigger.yml b/.github/workflows/trigger.yml index 223c1a8cb..2d1827564 100644 --- a/.github/workflows/trigger.yml +++ b/.github/workflows/trigger.yml @@ -9,18 +9,26 @@ on: - ci-work workflow_dispatch: +# A label event that neither starts nor stops a run is skipped by +# check-trigger, keep those in a group of their own so they cannot cancel +# a run in progress. Adding 'ci:skip' does share the group, on purpose, +# to stop a build that is no longer wanted. concurrency: - group: ci-${{ github.event.pull_request.number || github.ref }} + group: ci-${{ github.event.pull_request.number || github.ref }}${{ (github.event.action == 'labeled' && github.event.label.name != 'ci:main' && github.event.label.name != 'ci:skip') && '-label' || '' }} cancel-in-progress: true jobs: # Gate all builds through this check to prevent wasted runs. Only run on # 'labeled' events when the label is actually 'ci:main'. Concurrency control - # above handles canceling the 'opened' event when 'labeled' arrives quickly + # above handles canceling the 'opened' event when 'ci:main' arrives quickly # after (e.g., when creating a PR with ci:main already attached). See #1154. + # + # A PR labeled 'ci:skip' builds nothing, for changes that cannot affect + # the image, e.g. a ChangeLog fixup after someone else's branch landed. check-trigger: if: | startsWith(github.repository, 'kernelkit/') && + !contains(github.event.pull_request.labels.*.name, 'ci:skip') && (github.event_name != 'pull_request' || github.event.action != 'labeled' || github.event.label.name == 'ci:main') diff --git a/board/common/rootfs/usr/bin/dir b/board/common/rootfs/usr/bin/dir index d28eb47e8..65c106af1 100755 --- a/board/common/rootfs/usr/bin/dir +++ b/board/common/rootfs/usr/bin/dir @@ -2,12 +2,8 @@ dir() { - path=$1 - - if [ -z "$COLUMS" ]; then - TTY=$(resize) - eval "$TTY" - fi + path=${1%/} + [ -n "$path" ] || path=/ printf "\033[7m%-*s\033[0m\n" "$COLUMNS" "$path directory" if [ -d "$path" ]; then @@ -18,14 +14,35 @@ dir() echo } -if [ -d "$1" ]; then - dir "$1" -else +# Directories copy(1) can read and write, and the user is likely to +# browse. The TFTP root follows the server when it is enabled. +locations() +{ if [ "$USER" = "root" ]; then - dir "$HOME" + echo "$HOME" else - dir "/home/$USER" + echo "/home/$USER" fi - dir "/cfg" - dir "/log" + echo "/cfg" + sed -n 's/^tftp-root=//p' /etc/dnsmasq.d/tftp.conf 2>/dev/null || true + echo "/var/lib/tftpboot" + echo "/media" + echo "/log" +} + +# resize(1) talks to the terminal, so it has to run before any pipeline +# that would hand it something else on stdin +if [ -z "$COLUMNS" ]; then + TTY=$(resize) + eval "$TTY" +fi +: "${COLUMNS:=80}" + +if [ -n "$1" ]; then + dir "$1" +else + # The configured TFTP root may well be the default one + for path in $(locations | awk '!seen[$0]++'); do + [ -d "$path" ] && dir "$path" + done fi diff --git a/board/common/rootfs/usr/lib/tmpfiles.d/tftp.conf b/board/common/rootfs/usr/lib/tmpfiles.d/tftp.conf new file mode 100644 index 000000000..2db08ad05 --- /dev/null +++ b/board/common/rootfs/usr/lib/tmpfiles.d/tftp.conf @@ -0,0 +1 @@ +d /var/lib/tftpboot 2775 root wheel diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 1d5f9a66b..e40048e0c 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -22,9 +22,13 @@ All notable changes to the project are documented in this file. - WebUI: the support bundle is collected with the `infix-system:support-collect` RPC as the logged-in user, so NACM decides who may download it, rather than by running the tool as root +- The CLI `dir` command lists directories as the logged-in user, so it + shows only what that user may read ### Added +- The CLI accepts an unambiguous prefix of a command name, e.g. `sh int` + for `show interface` - Add `/system/advanced` for low-level system customization, issue #463: - `rc.d`: user scripts stored in the configuration, run once at boot after the startup configuration has been applied, in the order listed @@ -43,6 +47,14 @@ All notable changes to the project are documented in this file. statistics` replaces `dhcp-server clear-statistics`. `set datetime` now also accepts free-form input, e.g., `14:05`, and echoes the ISO-8601 value it sets +- The CLI `remove` command now offers the startup configuration, and + warns that removing it leaves the system booting factory defaults +- Add CLI `rename` command, for renaming or moving a file without + copying it, e.g. `rename startup-config backup` to keep a + configuration before starting over. Directories in the destination + are created as needed +- The CLI completes file system paths with Tab, for `copy`, `rename`, + `remove`, and `dir`, limited to the directories those commands accept - The CLI `configure` command takes an optional path to start in a sub-context directly, e.g., `configure system authentication` - `/bin/sh` is now provided by Busybox ash instead of Bash, speeding up @@ -67,6 +79,16 @@ All notable changes to the project are documented in this file. as `/interfaces/interface[name='eth0']/if-index`. SNMP SET, SNMPv3, notifications and view-based access control are not supported. Disabled by default, see [SNMP][snmp] +- Add TFTP server for network boot and device provisioning, issue #1542. + Read-only, serving `/var/lib/tftpboot` or a directory on USB media, with + optional per-client subdirectories. `show tftp` lists the files served, + see [TFTP Server](tftp.md) +- Add network boot parameters to DHCP server: `boot file`, `server-address`, + and `server-name` at global, subnet, or host scope, sent in the BOOTP header + fields and as options 66/67 +- The CLI `copy` and `remove` commands now also accept files in `/var/lib`, + `/var/tmp`, and `/tmp`. Files written there are world-readable. + The `.cfg`extension is only added for files in `/cfg` ### Fixes diff --git a/doc/README.md b/doc/README.md index 8b7b74d8f..0b24bb3ae 100644 --- a/doc/README.md +++ b/doc/README.md @@ -23,6 +23,7 @@ regression test system solely relies on NETCONF and RESTCONF. - [Network Configuration](networking.md) - [Wi-Fi](wifi.md) - [DHCP Server](dhcp.md) + - [TFTP Server](tftp.md) - [Syslog Support](syslog.md) - **Infix In-Depth** - [Boot Procedure](boot.md) diff --git a/doc/developers-guide.md b/doc/developers-guide.md index a09559866..a332059fe 100644 --- a/doc/developers-guide.md +++ b/doc/developers-guide.md @@ -523,6 +523,14 @@ $ git submodule update --init > in the GUI for your fork for this purpose. A cronjob on your server > of choice can do this for you with the [GitHub CLI tool][7]. +CI on a pull request is controlled with two labels. With neither, the +minimal images are built and the default tests run. + + - `ci:main` builds the full images and runs the complete test suite + - `ci:skip` builds nothing, for changes that cannot affect the image, + e.g. a ChangeLog fixup after another branch landed. Adding it to an + open pull request also stops a build already running + [^1]: Organizations should make sure to lock the `main` (or `master`) branch of their clones to ensure members do not accidentally merge changes there. Keeping these branches in sync with upstream Infix diff --git a/doc/dhcp.md b/doc/dhcp.md index 6898ebd91..8198191cd 100644 --- a/doc/dhcp.md +++ b/doc/dhcp.md @@ -140,6 +140,44 @@ admin@example:/config/dhcp-server/subnet/192.168.2.0/24/> leave +## Network Boot + +Devices that boot over the network, or fall back to it when their own +firmware is damaged, learn the name of the boot file and the address of +the TFTP server from the DHCP server. These are set with `boot`, which +can be given globally, per subnet, or per static host. The most +specific scope wins. + +
admin@example:/config/dhcp-server/> edit subnet 192.168.2.0/24
+admin@example:/config/dhcp-server/subnet/192.168.2.0/24/> set boot file fallback.itb
+admin@example:/config/dhcp-server/subnet/192.168.2.0/24/> leave
+
+ +By default the server address handed out is the system's own address +on the interface facing the client, matching the built-in +[TFTP server](tftp.md). Set `boot server-address` to point clients at +another server instead. + +The boot file and server address are sent both in the BOOTP header +fields, which BOOTP clients, bootloaders like U-Boot, and PXE ROMs read, +and as DHCP options 66 and 67 to clients that request them. The two +options cannot be set in the `option` list when `boot` is used. + +> [!CAUTION] +> Boot files are handed out over TFTP, which has no authentication. +> Anything below the server root can be read by any client, see +> [TFTP Server](tftp.md). + +To hand a single device a different image, e.g., during a staged +rollout, set `boot` on its static host entry: + +
admin@example:/config/dhcp-server/subnet/192.168.2.0/24/> edit host 192.168.2.10
+admin@example:/config/dhcp-server/…/192.168.2.10/> set match mac-address 00:11:22:33:44:55
+admin@example:/config/dhcp-server/…/192.168.2.10/> set boot file staging.itb
+admin@example:/config/dhcp-server/…/192.168.2.10/> leave
+
+ + ## Monitoring View active leases and server statistics: diff --git a/doc/tftp.md b/doc/tftp.md new file mode 100644 index 000000000..96308e565 --- /dev/null +++ b/doc/tftp.md @@ -0,0 +1,106 @@ +TFTP Server +=========== + +The TFTP server hands out files to devices on the local network, for +example a fallback boot image for devices whose own firmware partition +has failed, or configuration files for IP phones and similar equipment. +It is read-only, so clients cannot upload files. + +Files are served from a root directory, by default `/var/lib/tftpboot`. +This directory is persistent on all supported boards and writable by +admin users, so files can be placed there from the CLI or a shell. A +directory on USB media, e.g., `/media/usb/tftp`, can be used instead. + +> [!IMPORTANT] +> Only world-readable files are served. Files copied with the CLI +> `copy` command are made world-readable automatically, files copied +> from a shell must be given mode `0644` or similar. + +> [!CAUTION] +> TFTP has no authentication. Every file below the root can be read by +> any client that reaches the server, so never keep a saved system +> configuration there -- it carries password hashes, keys, and other +> secrets. Limit the server to the interfaces facing the clients. + + +## Basic Configuration + +
admin@example:/> configure
+admin@example:/config/> set tftp enabled true
+admin@example:/config/> leave
+
+ +The server listens on all interfaces by default. To restrict it to a +subset, list the interfaces to serve on: + +
admin@example:/config/> edit tftp
+admin@example:/config/tftp/> set interface eth1
+admin@example:/config/tftp/> set interface eth2
+admin@example:/config/tftp/> leave
+
+ +When the firewall is enabled, the `tftp` service must also be allowed +in the zone facing the clients, see [Firewall](firewall.md). + + +## Uploading Files + +Files can be fetched to the TFTP root with the `copy` command from any +of the supported remote sources, or copied from USB media. A directory +destination keeps the source file name: + +
admin@example:/> copy tftp://192.168.1.1/fallback.itb /var/lib/tftpboot/
+admin@example:/> copy /media/usb/phones.cfg /var/lib/tftpboot/
+admin@example:/> dir /var/lib/tftpboot
+/var/lib/tftpboot directory
+fallback.itb   phones.cfg
+
+ +Files are removed with the `remove` command, which asks for +confirmation: + +
admin@example:/> remove /var/lib/tftpboot/phones.cfg
+Remove /var/lib/tftpboot/phones.cfg, are you sure? (y/N)? y
+
+ + +## Per-Client Directories + +Some devices, IP phones in particular, expect a configuration file with +a fixed name that differs per device. With `client-directory` set, the +server first looks for the requested file in a subdirectory of the root +named after the client, and falls back to the root itself if there is +none: + +
admin@example:/config/tftp/> set client-directory mac
+
+ +With this setting a request for `config.xml` from the device with MAC +address `00:11:22:33:44:55` is answered with +`/var/lib/tftpboot/00-11-22-33-44-55/config.xml` if that file exists, +otherwise with `/var/lib/tftpboot/config.xml`. Use `ip` instead of +`mac` to name the directories after the client IP address. + + +## Network Boot + +Devices that boot over the network learn the boot file name and TFTP +server address from the DHCP server. See [Network Boot](dhcp.md#network-boot) +in the DHCP server documentation for how to hand these out. + + +## Monitoring + +
admin@example:/> show tftp
+Root directory   : /var/lib/tftpboot
+Interfaces       : all
+Client directory : none
+
+NAME          SIZE  MODIFIED        
+fallback.itb  7.0M  2026-09-18 05:18
+phones.cfg    812B  2026-09-17 12:00
+
+ +The file list is the operational view of the root directory and shows +only files the server can actually hand out. A file missing from the +list is either not world-readable or outside the configured root. diff --git a/mkdocs.yml b/mkdocs.yml index fae48080a..e9a0bd2eb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,7 +24,7 @@ nav: - Network Calculator: cli/netcalc.md - Network Monitoring: cli/tcpdump.md - Quickstart Guide: cli/quick.md - - Text Editor: cli/text-editor.md + - Text Editor: cli/edit.md - Upgrading: cli/upgrade.md - Docker Containers: container.md - Networking: @@ -50,6 +50,7 @@ nav: - NTP Server: ntp.md - PTP (IEEE 1588/802.1AS): ptp.md - SNMP: snmp.md + - TFTP Server: tftp.md - System: - Boot Procedure: boot.md - Configuration: system.md diff --git a/package/bin/bin.mk b/package/bin/bin.mk index d00c66258..b54bfb873 100644 --- a/package/bin/bin.mk +++ b/package/bin/bin.mk @@ -41,10 +41,4 @@ define BIN_BUILD_PYTHON endef BIN_POST_INSTALL_TARGET_HOOKS += BIN_BUILD_PYTHON -define BIN_INSTALL_BASH_COMPLETION - install -D $(@D)/bash_completion.d/show \ - $(TARGET_DIR)/etc/bash_completion.d/show -endef -BIN_POST_INSTALL_TARGET_HOOKS += BIN_INSTALL_BASH_COMPLETION - $(eval $(autotools-package)) diff --git a/package/klish/klish.hash b/package/klish/klish.hash index 8d8b6eedf..8e164761f 100644 --- a/package/klish/klish.hash +++ b/package/klish/klish.hash @@ -1,3 +1,3 @@ # Locally calculated sha256 9d9d33b873917ca5d0bdcc47a36d2fd385971ab0c045d1472fcadf95ee5bcf5b LICENCE -sha256 be6548a5a4f8c35906b02ea0ccb64cdd94d48bfe7133801353fbf658aa33d5c0 klish-8dca4da70a7794d5f4e0b047724bfe9e2088ebf3-git4.tar.gz +sha256 f8c944f5a11a07044a50ed2520c192e56b555e4e931e41403b4e7f9da45560d6 klish-5880300b23ea7378a4af186d510a61be64dfd630-git4.tar.gz diff --git a/package/klish/klish.mk b/package/klish/klish.mk index a1d7ff6f8..21480489d 100644 --- a/package/klish/klish.mk +++ b/package/klish/klish.mk @@ -4,7 +4,7 @@ # ################################################################################ -KLISH_VERSION = 8dca4da70a7794d5f4e0b047724bfe9e2088ebf3 +KLISH_VERSION = 5880300b23ea7378a4af186d510a61be64dfd630 KLISH_SITE = https://github.com/kernelkit/klish.git #KLISH_VERSION = tags/3.0.0 #KLISH_SITE = https://src.libcode.org/pkun/klish.git diff --git a/src/bin/.gitignore b/src/bin/.gitignore index 88967e2be..d9cd1745d 100644 --- a/src/bin/.gitignore +++ b/src/bin/.gitignore @@ -1,8 +1,9 @@ *~ *.o -copy -erase -files +/copy +/erase +/files +/rename /aclocal.m4 /autom4te.cache/ diff --git a/src/bin/Makefile.am b/src/bin/Makefile.am index 0201be3df..31c9067a5 100644 --- a/src/bin/Makefile.am +++ b/src/bin/Makefile.am @@ -1,11 +1,13 @@ DISTCLEANFILES = *~ *.d ACLOCAL_AMFLAGS = -I m4 -bin_PROGRAMS = copy erase files +bin_PROGRAMS = copy erase files rename # Bash completion bashcompdir = $(datadir)/bash-completion/completions -dist_bashcomp_DATA = copy.bash +dist_bashcomp_DATA = bash_completion.d/copy bash_completion.d/erase \ + bash_completion.d/rename bash_completion.d/rpc \ + bash_completion.d/show copy_SOURCES = copy.c util.c util.h copy_CPPFLAGS = -D_DEFAULT_SOURCE -D_GNU_SOURCE @@ -19,6 +21,12 @@ erase_CFLAGS = -W -Wall -Wextra erase_CFLAGS += $(libite_CFLAGS) $(sysrepo_CFLAGS) erase_LDADD = $(libite_LIBS) $(sysrepo_LIBS) +rename_SOURCES = rename.c util.c util.h +rename_CPPFLAGS = -D_DEFAULT_SOURCE -D_GNU_SOURCE +rename_CFLAGS = -W -Wall -Wextra +rename_CFLAGS += $(libite_CFLAGS) $(sysrepo_CFLAGS) +rename_LDADD = $(libite_LIBS) $(sysrepo_LIBS) + files_SOURCES = files.c util.c util.h files_CPPFLAGS = -D_DEFAULT_SOURCE -D_GNU_SOURCE files_CFLAGS = -W -Wall -Wextra diff --git a/src/bin/bash_completion.d/copy b/src/bin/bash_completion.d/copy new file mode 100644 index 000000000..836d502aa --- /dev/null +++ b/src/bin/bash_completion.d/copy @@ -0,0 +1,116 @@ +# bash completion for copy command +# SPDX-License-Identifier: ISC + +# Complete files and directories. +_copy_files() +{ + local cur="$1" reset dir + local IFS=$'\n' + local -a files hide + + # Hide dotfiles, unless one is being typed. IFS is a newline here, + # so the filter has to be an array, it would not split into words. + if [[ ${cur} == */* ]]; then + hide=(-X "*/.*") + else + hide=(-X ".*") + fi + [[ ${cur##*/} == .* ]] && hide=() + + # compgen output is filenames, keep the shell from globbing them + reset=$(shopt -po noglob) + set -o noglob + files=( $(compgen -f "${hide[@]}" -- "${cur}") ) + IFS=' ' + ${reset} + IFS=$'\n' + + [[ ${#files[@]} -eq 0 ]] && return + + COMPREPLY+=( "${files[@]}" ) + + # Only for file names, it would escape the colon in a URI. compopt + # fails when called outside of completion, e.g. when testing. + compopt -o filenames 2>/dev/null + + # When listing, readline marks every directory, but when completing + # one it leaves a symlink to a directory unmarked and ends the word + # with a space. Only the sole match is ever completed, so mark that + # one here and hold the space back. + if [[ ${#COMPREPLY[@]} -eq 1 ]]; then + # compgen keeps a leading ~, expand it to test the path + dir="${COMPREPLY[0]}" + [[ ${dir} == "~/"* ]] && dir="${HOME}/${dir#\~/}" + + if [[ -L ${dir} && -d ${dir} ]]; then + COMPREPLY[0]="${COMPREPLY[0]}/" + compopt -o nospace 2>/dev/null + fi + fi +} + +_copy_completion() +{ + local cur prev opts argopts uris datastores i arg_count + COMPREPLY=() + + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + opts="-d -f -h -n -r -s -t -u -v -x" + argopts="-t|-u|-x" + uris="ftp:// http:// https:// scp:// sftp:// tftp://" + + # Timeout, username, and XPath take a value we cannot complete + case "${prev}" in + ${argopts}) + return 0 + ;; + esac + + if [[ ${cur} == -* ]]; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + + # Determine position, source or destination, skipping options + arg_count=0 + for ((i=1; i < COMP_CWORD; i++)); do + case "${COMP_WORDS[i]}" in + ${argopts}) + # Flag with a value, skip it too + ((i++)) + ;; + -*) + ;; + *) + ((++arg_count)) + ;; + esac + done + + case ${arg_count} in + 0) + datastores="factory-config operational-state running-config startup-config" + ;; + 1) + # factory-config and operational-state are not writable + datastores="running-config startup-config" + ;; + *) + return 0 + ;; + esac + + COMPREPLY=( $(compgen -W "${datastores} ${uris}" -- "${cur}") ) + _copy_files "${cur}" + + # A URI is a prefix, not a word, let the user keep typing + if [[ ${#COMPREPLY[@]} -eq 1 && ${COMPREPLY[0]} == *:// ]]; then + compopt -o nospace 2>/dev/null + fi + + return 0 +} + +complete -F _copy_completion copy diff --git a/src/bin/bash_completion.d/erase b/src/bin/bash_completion.d/erase new file mode 100644 index 000000000..dc62ad639 --- /dev/null +++ b/src/bin/bash_completion.d/erase @@ -0,0 +1,24 @@ +# bash completion for erase command +# SPDX-License-Identifier: ISC + +# The file listing is shared with copy +. "${BASH_SOURCE%/*}/copy" + +_erase_completion() +{ + local cur + + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + + if [[ ${cur} == -* ]]; then + COMPREPLY=( $(compgen -W "-h -s -v" -- "${cur}") ) + return 0 + fi + + _copy_files "${cur}" + + return 0 +} + +complete -F _erase_completion erase diff --git a/src/bin/bash_completion.d/rename b/src/bin/bash_completion.d/rename new file mode 100644 index 000000000..b0743aabe --- /dev/null +++ b/src/bin/bash_completion.d/rename @@ -0,0 +1,24 @@ +# bash completion for rename command +# SPDX-License-Identifier: ISC + +# The file listing is shared with copy +. "${BASH_SOURCE%/*}/copy" + +_rename_completion() +{ + local cur + + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + + if [[ ${cur} == -* ]]; then + COMPREPLY=( $(compgen -W "-f -h -s -v" -- "${cur}") ) + return 0 + fi + + _copy_files "${cur}" + + return 0 +} + +complete -F _rename_completion rename diff --git a/src/bin/bash_completion.d/rpc b/src/bin/bash_completion.d/rpc new file mode 100644 index 000000000..e597f6141 --- /dev/null +++ b/src/bin/bash_completion.d/rpc @@ -0,0 +1,71 @@ +# bash completion for rpc command +# SPDX-License-Identifier: ISC + +# The RPCs a system accepts follow its YANG models, so read them from +# the models themselves rather than keeping a list here. Only top-level +# RPCs are listed, actions are tied to a node in the data tree and need +# the path to it, which cannot be had from the model alone. +# +# The prefix is the module name from the file, not the file name, a +# submodule's RPCs belong to the module it is part of. Reading from +# /dev/null keeps awk off the terminal when no model is installed. +_rpc_xpaths() +{ + if [ -z "${_rpc_xpath_cache}" ]; then + _rpc_xpath_cache=$(awk ' + FNR == 1 { mod = "" } + !mod && $1 == "module" { mod = $2; sub(/[{;].*/, "", mod) } + $1 == "belongs-to" { mod = $2; sub(/[{;].*/, "", mod) } + $1 == "rpc" && NF <= 3 { + rpc = $2 + sub(/[{;].*/, "", rpc) + if (mod && rpc) + print "/" mod ":" rpc + }' /usr/share/yang/modules/*/*.yang 2>/dev/null path); } else { - *path = cfg_adjust(*dst, NULL, sanitize); + *path = cfg_adjust(*dst, src, sanitize); } if (!*path) { - warn("no such file: %s", *dst); + warnx("%s: no such file, or not an allowed path", *dst); return 1; } @@ -851,7 +860,7 @@ static int copy(const char *src, const char *dst) dst = dst_uri; } - err = resolve_dst(&dst, &dstds, &dstpath); + err = resolve_dst(src, &dst, &dstds, &dstpath); if (err) goto err; diff --git a/src/bin/erase.c b/src/bin/erase.c index d78ba1a40..9b13b6de7 100644 --- a/src/bin/erase.c +++ b/src/bin/erase.c @@ -20,16 +20,22 @@ static int do_erase(const char *name) path = cfg_adjust(name, NULL, sanitize); if (!path) { - fprintf(stderr, ERRMSG "file not found.\n"); + fprintf(stderr, "%s: %s: no such file, or not an allowed path\n", + prognm, name); rc = 1; goto out; } + if (!strcmp(path, STARTUP_CONFIG)) + fprintf(stderr, "Note: without it the system boots factory defaults" + " on next start.\n To keep this configuration, rename" + " it instead.\n"); + if (!yorn("Remove %s, are you sure?", path)) goto out; if (remove(path)) { - fprintf(stderr, ERRMSG "failed removing %s: %s\n", path, strerror(errno)); + fprintf(stderr, "%s: failed removing %s: %s\n", prognm, path, strerror(errno)); rc = 11; } diff --git a/src/bin/files.c b/src/bin/files.c index e289f55d8..5897fb64c 100644 --- a/src/bin/files.c +++ b/src/bin/files.c @@ -3,6 +3,8 @@ #include #include +#include +#include #include #include "util.h" @@ -17,7 +19,7 @@ int files(const char *path, const char *stripext) dir = opendir(path); if (!dir) { - fprintf(stderr, ERRMSG "%s", strerror(errno)); + fprintf(stderr, "%s: %s: %s\n", prognm, path, strerror(errno)); return -1; } @@ -28,10 +30,6 @@ int files(const char *path, const char *stripext) if (d->d_type != DT_REG || d->d_name[0] == '.') continue; - /* skip startup in /cfg, listed by plugin */ - if (!strcmp(path, "/cfg") && !strcmp(d->d_name, "startup-config.cfg")) - continue; - strlcpy(name, d->d_name, sizeof(name)); if (stripext) { size_t pos = has_ext(name, stripext); @@ -47,11 +45,92 @@ int files(const char *path, const char *stripext) } +/* + * List what matches base in dir, marking directories so the path can be + * continued. Only locations copy(1) and erase(1) accept are shown. + */ +static void list(const char *dir, const char *base) +{ + const struct dirent *d; + char full[PATH_MAX]; + DIR *dp; + + dp = opendir(dir); + if (!dp) + return; + + while ((d = readdir(dp))) { + int isdir; + + if (!strcmp(d->d_name, ".") || !strcmp(d->d_name, "..")) + continue; + + /* Dotfiles only when one is being typed */ + if (d->d_name[0] == '.' && base[0] != '.') + continue; + + if (strncmp(d->d_name, base, strlen(base))) + continue; + + if (strlen(dir) + strlen(d->d_name) >= sizeof(full)) + continue; + + strlcpy(full, dir, sizeof(full)); + strlcat(full, d->d_name, sizeof(full)); + if (!path_traversable(full)) + continue; + + isdir = d->d_type == DT_DIR; + if (d->d_type == DT_LNK || d->d_type == DT_UNKNOWN) + isdir = fisdir(full); + + printf("%s%s\n", full, isdir ? "/" : ""); + } + closedir(dp); +} + +/* Complete an absolute path for the CLI */ +static int complete(const char *word) +{ + char dir[PATH_MAX]; + const char *base; + char *slash, *real; + + /* Absolute paths only, and copy(1) refuses '..' in any case */ + if (word[0] != '/' || strstr(word, "..")) + return 0; + + if (strlen(word) >= sizeof(dir)) + return 0; + strlcpy(dir, word, sizeof(dir)); + + slash = strrchr(dir, '/'); + base = word + (slash - dir) + 1; + slash[1] = 0; + + /* A symlink may lead out, the directory read must be allowed */ + real = realpath(dir, NULL); + if (!real) + return 0; + + if (!path_traversable(real)) { + free(real); + return 0; + } + free(real); + + list(dir, base); + + return 0; +} + + static int usage(int rc) { printf("Usage: %s [OPTIONS] PATH [EXT]\n" "\n" "Options:\n" + " -c PATH Complete an absolute path, for CLI use\n" " -h This help text\n" " -v Show version\n", prognm); @@ -63,8 +142,10 @@ int main(int argc, char *argv[]) const char *path = NULL, *ext = NULL; int c; - while ((c = getopt(argc, argv, "hv")) != EOF) { + while ((c = getopt(argc, argv, "c:hv")) != EOF) { switch(c) { + case 'c': + return complete(optarg); case 'h': return usage(0); case 'v': diff --git a/src/bin/rename.c b/src/bin/rename.c new file mode 100644 index 000000000..4217e1bda --- /dev/null +++ b/src/bin/rename.c @@ -0,0 +1,132 @@ +/* SPDX-License-Identifier: ISC */ +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "util.h" + +static const char *prognm = "rename"; +static int sanitize; +static int force; + +/* Create the directory a file is moving into, with search where read */ +static int mkparent(const char *path) +{ + char dir[PATH_MAX]; + mode_t mode; + int len; + + len = dirlen(path); + if (len <= 0 || (size_t)len >= sizeof(dir)) + return 0; + + strlcpy(dir, path, (size_t)len + 1); + if (fisdir(dir)) + return 0; + + mode = path_mode(path) ?: 0660; + mode |= (mode & 0444) >> 2; + + if (mkpath(dir, mode)) + return -1; + + return chmod(dir, mode); +} + +static int do_rename(const char *from, const char *to) +{ + char *src = NULL, *dst = NULL; + mode_t mode; + int rc = 1; + + src = cfg_adjust(from, NULL, sanitize); + if (!src || access(src, F_OK)) { + fprintf(stderr, "%s: %s: no such file, or not an allowed path\n", + prognm, from); + goto out; + } + + dst = cfg_adjust(to, from, sanitize); + if (!dst) { + fprintf(stderr, "%s: %s: not an allowed path\n", prognm, to); + goto out; + } + + if (!force && !access(dst, F_OK) && !yorn("Overwrite existing file %s", dst)) + goto out; + + if (mkparent(dst)) { + fprintf(stderr, "%s: failed creating directory for %s: %s\n", + prognm, dst, strerror(errno)); + goto out; + } + + if (rename(src, dst)) { + if (errno == EXDEV) + fprintf(stderr, "%s: %s and %s are on different file systems," + " use copy and remove\n", prognm, src, dst); + else + fprintf(stderr, "%s: failed renaming %s: %s\n", prognm, src, + strerror(errno)); + goto out; + } + + /* Keep the mode the destination calls for, it may be served */ + mode = path_mode(dst); + if (mode && chmod(dst, mode) && errno != EPERM) + fprintf(stderr, "%s: failed setting mode 0%o on %s: %s\n", prognm, + mode, dst, strerror(errno)); + + rc = 0; +out: + free(dst); + free(src); + + return rc; +} + +static int usage(int rc) +{ + printf("Usage: %s [OPTIONS] FROM TO\n" + "\n" + "Options:\n" + " -f Force, overwrite an existing file without asking\n" + " -h This help text\n" + " -s Sanitize paths for CLI use (restrict path traversal)\n" + " -v Show version\n", prognm); + + return rc; +} + +int main(int argc, char *argv[]) +{ + int c; + + while ((c = getopt(argc, argv, "fhsv")) != EOF) { + switch(c) { + case 'f': + force = 1; + break; + case 'h': + return usage(0); + case 's': + sanitize = 1; + break; + case 'v': + puts(PACKAGE_VERSION); + return 0; + } + } + + if (argc - optind != 2) + return usage(1); + + return do_rename(argv[optind], argv[optind + 1]); +} diff --git a/src/bin/show/__init__.py b/src/bin/show/__init__.py index ec063cc5e..fb418fc89 100755 --- a/src/bin/show/__init__.py +++ b/src/bin/show/__init__.py @@ -69,6 +69,18 @@ def dhcp(args: List[str]) -> None: cli_pretty(data, "show-dhcp-server") +def tftp(args: List[str]) -> None: + data = get_json("/infix-services:tftp") + if not data: + print("TFTP server not enabled.") + return + + if RAW_OUTPUT: + print(json.dumps(data, indent=2)) + return + cli_pretty(data, "show-tftp") + + def hardware(args: List[str]) -> None: data = get_json("/ietf-hardware:hardware") if not data: @@ -773,7 +785,8 @@ def execute_command(command: str, args: List[str]): 'snmp': snmp, 'software': software, 'stp': stp, - 'system': system + 'system': system, + 'tftp': tftp } if command in command_mapping: diff --git a/src/bin/util.c b/src/bin/util.c index 1226c3a6c..801d2e11e 100644 --- a/src/bin/util.c +++ b/src/bin/util.c @@ -3,15 +3,16 @@ #include #include +#include #include #include #include #include +#include #include #include "util.h" - static char rawgetch(void) { struct termios saved, c; @@ -93,76 +94,198 @@ const char *basenm(const char *path) return path; } -static int path_allowed(const char *path) +/* Directories the CLI may access, mode and default extension of files there */ +struct location { + const char *prefix; + mode_t mode; + const char *ext; +}; + +static const struct location allowed[] = { + { CFG_DIR, 0660, ".cfg" }, + { "/media/", 0664, "" }, + { "/var/lib/", 0664, "" }, + { "/var/log/", 0660, "" }, + { "/log/", 0660, "" }, + { "/var/tmp/", 0660, "" }, + { "/tmp/", 0660, "" }, +}; + +/* + * True when path is dir itself, or something below it. The match ends + * on a path component, so /home/jock does not cover /home/jocke. + */ +static bool path_within(const char *path, const char *dir) { - const char *accepted[] = { - "/media/", - "/cfg/", - getenv("HOME"), - NULL - }; - - for (int i = 0; accepted[i]; i++) { - if (!strncmp(path, accepted[i], strlen(accepted[i]))) - return 1; + size_t len; + + if (!path || !dir) + return false; + + len = strlen(dir); + while (len > 1 && dir[len - 1] == '/') + len--; + + if (strncmp(path, dir, len)) + return false; + + /* Root is above every path, others end on a component */ + if (len == 1 && dir[0] == '/') + return path[0] == '/'; + + return !path[len] || path[len] == '/'; +} + +static const struct location *path_lookup(const char *path) +{ + static struct location home = { NULL, 0660, "" }; + + home.prefix = getenv("HOME"); + if (path_within(path, home.prefix)) + return &home; + + for (size_t i = 0; i < NELEMS(allowed); i++) { + if (path_within(path, allowed[i].prefix)) + return &allowed[i]; } - return 0; + return NULL; +} + +/* + * True for a path inside an allowed location, and for the directories + * on the way to one, so a path can be walked to reach them. + */ +bool path_traversable(const char *path) +{ + const char *home = getenv("HOME"); + + if (path_mode(path)) + return true; + + /* On the way to a location, e.g. /var leading to /var/lib */ + if (path_within(home, path)) + return true; + + for (size_t i = 0; i < NELEMS(allowed); i++) { + if (path_within(allowed[i].prefix, path)) + return true; + } + + return false; +} + +mode_t path_mode(const char *path) +{ + const struct location *loc = path_lookup(path); + + return loc ? loc->mode : 0; +} + +/* + * A path that does not exist cannot be resolved, so the closest + * existing parent decides. Only it can be a symlink leading out + * of an allowed location. + */ +static bool parent_allowed(const char *path) +{ + char dir[PATH_MAX]; + char *resolved; + bool ok; + int len; + + for (len = dirlen(path); len > 0; len = dirlen(dir)) { + if ((size_t)len >= sizeof(dir)) + return false; + + strlcpy(dir, path, (size_t)len + 1); + resolved = realpath(dir, NULL); + if (resolved) { + ok = path_mode(resolved) != 0; + free(resolved); + + return ok; + } + + if (errno != ENOENT) + return false; + } + + return false; } char *cfg_adjust(const char *path, const char *template, bool sanitize) { - char *expanded = NULL, *resolved = NULL; + char *expanded = NULL, *resolved = NULL, *full; + const struct location *loc = NULL; + const char *prefix = ""; const char *basename; - int dlen; - - dlen = dirlen(path); - basename = basenm(path) ? : basenm(template); - if (!basename) - goto err; if (sanitize) { if (strstr(path, "../")) goto err; - if (path[0] == '/') { - if (!path_allowed(path)) - goto err; - } - /* CLI users save to /cfg by default, unless abs. path */ - if (asprintf(&expanded, "%s%.*s/%s%s", - path[0] == '/' ? "" : "/cfg/", - dlen, path, - basename, - strchr(basename, '.') ? "" : ".cfg") < 0) + if (path[0] != '/') + prefix = CFG_DIR; + + loc = path_lookup(*prefix ? prefix : path); + if (!loc) goto err; - } else { - /* Shell users expect copy to behave more like cp */ - expanded = strdup(path); } + if (asprintf(&expanded, "%s%s", prefix, path) < 0) + goto err; if (sanitize) { resolved = realpath(expanded, NULL); - if (!resolved) { - if (errno == ENOENT) - goto out; - else + if (resolved) { + /* Follow symlinks, the target must be allowed too */ + if (!path_mode(resolved)) goto err; + + free(expanded); + expanded = resolved; + resolved = NULL; + } else if (errno == ENOENT) { + if (!parent_allowed(expanded)) + goto err; + } else { + goto err; } + } + + /* Directory destination, copy into it like cp(1). A trailing + * slash says directory even when it is not there yet. + */ + if (template && (fisdir(expanded) || expanded[strlen(expanded) - 1] == '/')) { + size_t len = strlen(expanded); + + basename = basenm(template); + if (!basename) + goto err; + + /* The path may already end in a slash, do not double it */ + while (len > 1 && expanded[len - 1] == '/') + expanded[--len] = 0; + + if (asprintf(&full, "%s/%s", expanded, basename) < 0) + goto err; + + free(expanded); + expanded = full; + } - /* File exists, make sure that the resolved symlink - * still matches the whitelist. - */ - if (!path_allowed(resolved)) + /* Config files get an extension, if the name lacks one */ + basename = basenm(expanded); + if (loc && *loc->ext && basename && !strchr(basename, '.')) { + if (asprintf(&full, "%s%s", expanded, loc->ext) < 0) goto err; free(expanded); - expanded = resolved; + expanded = full; } -out: return expanded; err: diff --git a/src/bin/util.h b/src/bin/util.h index 516a5d5e3..77da19985 100644 --- a/src/bin/util.h +++ b/src/bin/util.h @@ -3,8 +3,12 @@ #define BIN_UTIL_H_ #include #include +#include #include +#define CFG_DIR "/cfg/" +#define STARTUP_CONFIG CFG_DIR "startup-config.cfg" + #define ERRMSG "Error: " #define DBGMSG "Debug: " #define INFMSG "Note: " @@ -14,7 +18,10 @@ int yorn (const char *fmt, ...); int files (const char *path, const char *stripext); const char *basenm (const char *fn); +int dirlen (const char *path); int has_ext (const char *fn, const char *ext); +mode_t path_mode (const char *path); +bool path_traversable(const char *path); char *cfg_adjust (const char *path, const char *template, bool sanitize); #endif /* BIN_UTIL_H_ */ diff --git a/src/confd/src/dhcp-server.c b/src/confd/src/dhcp-server.c index 8e464d095..7da94ca9d 100644 --- a/src/confd/src/dhcp-server.c +++ b/src/confd/src/dhcp-server.c @@ -62,6 +62,17 @@ static const char *host_tag(const char *subnet, const char *addr) return tag; } +static const char *tag_prefix(const char *tag) +{ + static char prefix[160]; + + if (!tag) + return ""; + + snprintf(prefix, sizeof(prefix), "tag:%s,", tag); + return prefix; +} + static int configure_options(FILE *fp, struct lyd_node *cfg, const char *tag) { struct lyd_node *option; @@ -94,16 +105,12 @@ static int configure_options(FILE *fp, struct lyd_node *cfg, const char *tag) } if (val) { - fprintf(fp, "dhcp-option=%s%s%s%d,%s\n", - tag ? "tag:" : "", tag ?: "", - tag ? "," : "", num, val); + fprintf(fp, "dhcp-option=%s%d,%s\n", tag_prefix(tag), num, val); } else if ((suboption = lydx_get_descendant(option, "option", "static-route", NULL))) { struct lyd_node *net; LYX_LIST_FOR_EACH(suboption, net, "static-route") { - fprintf(fp, "dhcp-option=%s%s%s%d,%s,%s\n", - tag ? "tag:" : "", - tag ?: "", tag ? "," : "", num, + fprintf(fp, "dhcp-option=%s%d,%s,%s\n", tag_prefix(tag), num, lydx_get_cattr(net, "destination"), lydx_get_cattr(net, "next-hop")); } @@ -116,6 +123,25 @@ static int configure_options(FILE *fp, struct lyd_node *cfg, const char *tag) return 0; } +static void configure_boot(FILE *fp, struct lyd_node *cfg, const char *tag) +{ + struct lyd_node *boot = lydx_get_child(cfg, "boot"); + const char *addr, *name; + + if (!boot) + return; + + addr = lydx_get_cattr(boot, "server-address"); + name = lydx_get_cattr(boot, "server-name"); + if (!addr || !strcmp(addr, "auto")) + addr = NULL; + + fprintf(fp, "dhcp-boot=%s%s", tag_prefix(tag), lydx_get_cattr(boot, "file")); + if (name || addr) + fprintf(fp, ",%s%s%s", name ?: "", addr ? "," : "", addr ?: ""); + fputc('\n', fp); +} + static const char *host_match(struct lyd_node *match, const char **id) { struct { @@ -169,6 +195,7 @@ static int configure_host(FILE *fp, struct lyd_node *host, const char *subnet) fprintf(fp, "\n# Host specific options\n"); if (configure_options(fp, host, tag)) return -1; + configure_boot(fp, host, tag); name = lydx_get_cattr(host, "hostname"); @@ -209,6 +236,8 @@ static void add(const char *subnet, struct lyd_node *cfg) rc = configure_options(fp, cfg, tag); if (rc) goto err; + /* Before hosts: dnsmasq prepends dhcp-boot entries, first tag match wins */ + configure_boot(fp, cfg, tag); LYX_LIST_FOR_EACH(lyd_child(cfg), node, "host") { if ((rc = configure_host(fp, node, tag))) @@ -298,7 +327,7 @@ static void del(const char *subnet, struct lyd_node *cfg) int dhcp_server_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd) { struct lyd_node *global, *cifs, *difs, *cif, *dif; - int enabled = 0, added = 0, deleted = 0; + int enabled = 0; sr_error_t err = 0; switch (event) { @@ -324,7 +353,7 @@ int dhcp_server_change(sr_session_ctx_t *session, struct lyd_node *config, struc const char *subnet = lydx_get_cattr(dif, "subnet"); if (lydx_get_op(dif) == LYDX_OP_DELETE) { - del(subnet, dif), deleted++; + del(subnet, dif); continue; } @@ -335,9 +364,9 @@ int dhcp_server_change(sr_session_ctx_t *session, struct lyd_node *config, struc continue; if (!enabled || !lydx_is_enabled(cif, "enabled")) - del(subnet, cif), deleted++; + del(subnet, cif); else - add(subnet, cif), added++; + add(subnet, cif); break; } } @@ -360,6 +389,7 @@ int dhcp_server_change(sr_session_ctx_t *session, struct lyd_node *config, struc } err = configure_options(fp, global, NULL); + configure_boot(fp, global, NULL); fclose(fp); if (err) goto err_done; @@ -375,10 +405,8 @@ int dhcp_server_change(sr_session_ctx_t *session, struct lyd_node *config, struc } } + finit_reload("dnsmasq"); err_done: - if (added || deleted) - finit_reload("dnsmasq"); - return err; } diff --git a/src/confd/src/services.c b/src/confd/src/services.c index 7006ea144..593690067 100644 --- a/src/confd/src/services.c +++ b/src/confd/src/services.c @@ -25,6 +25,7 @@ #define LLDP_CONFIG "/etc/lldpd.d/confd.conf" #define LLDP_CONFIG_NEXT LLDP_CONFIG"+" +#define DNSMASQ_TFTP_CONF "/etc/dnsmasq.d/tftp.conf" enum mdns_cmd { MDNS_ADD, MDNS_DELETE, MDNS_UPDATE }; @@ -70,6 +71,7 @@ static const int have_webui = 0; #define WEB_RESTCONF_XPATH WEB_XPATH"/restconf" #define WEB_NETBROWSE_XPATH WEB_XPATH"/netbrowse" #define WEB_CONSOLE_XPATH WEB_XPATH"/console" +#define TFTP_XPATH "/infix-services:tftp" typedef enum { FOREACH_SVC(GENERATE_ENUM) } svc; @@ -769,6 +771,68 @@ static int web_change(sr_session_ctx_t *session, struct lyd_node *config, struct return put(cfg); } +/* + * A newline in a value injects a directive into the snippet. The + * models reject it, this is the second line of defence. + */ +static bool tftp_sane(struct lyd_node *tftp) +{ + struct lyd_node *iface; + + if (strpbrk(lydx_get_cattr(tftp, "root") ?: "", "\r\n")) + return false; + + LYX_LIST_FOR_EACH(lyd_child(tftp), iface, "interface") { + if (strpbrk(lyd_get_value(iface) ?: "", "\r\n")) + return false; + } + + return true; +} + +static int tftp_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd) +{ + struct lyd_node *tftp, *iface; + const char *sep = "=", *mode; + FILE *fp; + + if (event != SR_EV_DONE || !lydx_get_xpathf(diff, TFTP_XPATH)) + return SR_ERR_OK; + + tftp = lydx_get_xpathf(config, TFTP_XPATH); + if (!lydx_is_enabled(tftp, "enabled")) { + if (!remove(DNSMASQ_TFTP_CONF)) + finit_reload("dnsmasq"); + return SR_ERR_OK; + } + + if (!tftp_sane(tftp)) { + ERROR("refusing TFTP settings with a newline in them"); + return SR_ERR_VALIDATION_FAILED; + } + + fp = fopen(DNSMASQ_TFTP_CONF, "w"); + if (!fp) { + ERRNO("failed creating %s", DNSMASQ_TFTP_CONF); + return SR_ERR_SYS; + } + + fputs("enable-tftp", fp); + LYX_LIST_FOR_EACH(lyd_child(tftp), iface, "interface") { + fprintf(fp, "%s%s", sep, lyd_get_value(iface)); + sep = ","; + } + fprintf(fp, "\ntftp-root=%s\ntftp-no-fail\n", lydx_get_cattr(tftp, "root")); + + mode = lydx_get_cattr(tftp, "client-directory"); + if (mode) + fprintf(fp, "tftp-unique-root=%s\n", mode); + fclose(fp); + finit_reload("dnsmasq"); + + return SR_ERR_OK; +} + int services_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd) { int rc; @@ -792,6 +856,9 @@ int services_change(sr_session_ctx_t *session, struct lyd_node *config, struct l if (rc) return rc; rc = netbrowse_change(session, config, diff, event, confd); + if (rc) + return rc; + rc = tftp_change(session, config, diff, event, confd); if (rc) return rc; return SR_ERR_OK; diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 008965499..d3b49ef7f 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -37,12 +37,12 @@ MODULES=( "infix-dhcp-common@2025-12-21.yang" "infix-dhcp-client@2025-11-09.yang" "infix-dhcpv6-client@2025-11-09.yang" - "infix-dhcp-server@2025-10-28.yang" + "infix-dhcp-server@2026-09-18.yang" "infix-firewall@2026-07-02.yang" "infix-firewall-services@2025-04-26.yang" "infix-firewall-icmp-types@2025-04-26.yang" "infix-meta@2025-12-10.yang" - "infix-services@2026-06-17.yang" + "infix-services@2026-09-18.yang" "infix-system@2026-09-11.yang" "ieee802-ethernet-interface@2025-09-10.yang" "ieee802-ethernet-phy-type@2025-09-10.yang" diff --git a/src/confd/yang/confd/infix-dhcp-server.yang b/src/confd/yang/confd/infix-dhcp-server.yang index 10acf7fb1..68f847cdd 100644 --- a/src/confd/yang/confd/infix-dhcp-server.yang +++ b/src/confd/yang/confd/infix-dhcp-server.yang @@ -20,6 +20,12 @@ module infix-dhcp-server { contact "kernelkit@googlegroups.com"; description "This module implements a DHCPv4 server"; + revision 2026-09-18 { + description "Add network boot parameters (BOOTP siaddr/file, option 66/67) + at global, subnet, and host scope."; + reference "internal"; + } + revision 2025-10-28 { description "Make pool a presence container and add pool validation. Also, require each subnet to have either a pool or @@ -49,6 +55,18 @@ module infix-dhcp-server { default "3600"; } + typedef ipv4-address-or-auto { + description "IPv4 address, or 'auto' to use the address of the DHCP server."; + type union { + type inet:ipv4-address; + type enumeration { + enum auto { + description "Use IP address of the DHCP server."; + } + } + } + } + grouping dhcp-option-group { description "Generic list structure for DHCP options."; @@ -66,14 +84,7 @@ module infix-dhcp-server { when "id = 'router' or id = 'dns-server' or id = 'log-server' or id = 'ntp-server' or id = 'netmask' or id = 'broadcast'"; leaf address { description "IP address, or 'auto'."; - type union { - type inet:ipv4-address; - type enumeration { - enum auto { - description "Use IP address of the DHCP server."; - } - } - } + type ipv4-address-or-auto; } } @@ -122,6 +133,44 @@ module infix-dhcp-server { } } + grouping dhcp-boot-group { + description "Grouping for network boot parameters."; + + container boot { + presence "Enable network boot parameters at this scope."; + description "Boot file and TFTP server handed to clients, sent both in + the BOOTP header fields (file, siaddr, sname) and as DHCP + options 66 and 67 to clients that request them. + + The most specific scope wins: host, then subnet, then + global."; + + must "not(../option[id = 'tftp-server' or id = 'bootfile' or id = 66 or id = 67])" { + error-message "Options 66 and 67 are set by boot, remove them from the option list."; + } + + leaf file { + description "Boot file name, relative to the TFTP server root."; + type string { + length "1..127"; + pattern '[^\p{Cc},]+'; + } + mandatory true; + } + + leaf server-address { + description "IP address of the TFTP server, or 'auto'."; + type ipv4-address-or-auto; + default auto; + } + + leaf server-name { + description "Optional TFTP server name, DHCP option 66."; + type inet:domain-name; + } + } + } + container dhcp-server { description "DHCPv4 server configuration."; @@ -137,6 +186,8 @@ module infix-dhcp-server { } } + uses dhcp-boot-group; + list subnet { description "Subnet specific settings, including static host entries."; key "subnet"; @@ -174,6 +225,8 @@ module infix-dhcp-server { } } + uses dhcp-boot-group; + container pool { presence "Enable dynamic DHCP address pool for this subnet."; description "IP address pool for this subnet."; @@ -283,6 +336,8 @@ module infix-dhcp-server { By default, global and subnet options are inherited."; } } + + uses dhcp-boot-group; } } diff --git a/src/confd/yang/confd/infix-dhcp-server@2025-10-28.yang b/src/confd/yang/confd/infix-dhcp-server@2026-09-18.yang similarity index 100% rename from src/confd/yang/confd/infix-dhcp-server@2025-10-28.yang rename to src/confd/yang/confd/infix-dhcp-server@2026-09-18.yang diff --git a/src/confd/yang/confd/infix-services.yang b/src/confd/yang/confd/infix-services.yang index c23651ec5..410c92564 100644 --- a/src/confd/yang/confd/infix-services.yang +++ b/src/confd/yang/confd/infix-services.yang @@ -31,6 +31,10 @@ module infix-services { contact "kernelkit@googlegroups.com"; description "Infix services, generic."; + revision 2026-09-18 { + description "Add TFTP server."; + reference "internal"; + } revision 2026-06-17 { description "Add web-ui feature, advertised when the web management interface (webui) is built into the image."; @@ -325,4 +329,67 @@ module infix-services { } } } + + container tftp { + description "Read-only TFTP server for network boot and provisioning."; + + leaf enabled { + description "Enable or disable the TFTP server."; + type boolean; + default false; + } + + leaf root { + description "Directory to serve files from, below /var/lib/tftpboot or + /media. Requests for files outside it are refused, and + only world-readable files are served."; + type string { + pattern '(/var/lib/tftpboot|/media/[^/.\p{Cc}][^/\p{Cc}]*)(/[^/.\p{Cc}][^/\p{Cc}]*)*'; + } + default "/var/lib/tftpboot"; + } + + leaf-list interface { + description "Serve TFTP on these interfaces only, default all."; + type if:interface-ref; + } + + leaf client-directory { + description "Look for files in a subdirectory of the root named after + the client before falling back to the root itself. Used + for per-device files, e.g., configuration."; + type enumeration { + enum ip { + description "Client IP address, e.g., 192.168.2.10"; + } + enum mac { + description "Client MAC address, lower case with dashes, + e.g., 00-11-22-33-44-55"; + } + } + } + + container files { + description "Files served, i.e., world-readable files below the root."; + config false; + + list file { + key name; + + leaf name { + description "File name, relative to the TFTP root."; + type string; + } + + leaf size { + type uint64; + units "bytes"; + } + + leaf modified { + type yang:date-and-time; + } + } + } + } } diff --git a/src/confd/yang/confd/infix-services@2026-06-17.yang b/src/confd/yang/confd/infix-services@2026-09-18.yang similarity index 100% rename from src/confd/yang/confd/infix-services@2026-06-17.yang rename to src/confd/yang/confd/infix-services@2026-09-18.yang diff --git a/src/klish-plugin-infix/src/infix.c b/src/klish-plugin-infix/src/infix.c index 01bd48306..5f506c7d6 100644 --- a/src/klish-plugin-infix/src/infix.c +++ b/src/klish-plugin-infix/src/infix.c @@ -103,6 +103,10 @@ static int run_as_user(const char *user, char *const argv[]) return -1; if (!pid) { + setenv("USER", user, 1); + setenv("LOGNAME", user, 1); + setenv("HOME", pw->pw_dir, 1); + if (initgroups(user, pw->pw_gid) || setgid(pw->pw_gid) || setuid(pw->pw_uid)) { fprintf(stderr, "Aborting, failed dropping privileges to " "(UID:%d GID:%d): %s\n", @@ -169,33 +173,74 @@ static int shellf(const char *fmt, ...) return rc; } +/* + * Complete an absolute path, the CLI has no shell to do it for us. + * Run as the logged-in user, like copy does, so the listing follows + * that user's home and permissions rather than klishd's. + */ +static int complete_path(kcontext_t *ctx, const char *word) +{ + char *argv[] = { "doas", "-u", NULL, "files", "-c", NULL, NULL }; + + argv[2] = (char *)cd_home(ctx); + argv[5] = (char *)word; + + return run(argv); +} + +/* Paths are completed from the file system, everything else by name */ +static int is_path(kcontext_t *ctx, const char **word) +{ + *word = kcontext_candidate_value(ctx); + + return *word && (*word)[0] == '/'; +} + +/* Files in dir, by bare name, as the CLI has always offered them */ +static int list_files(kcontext_t *ctx, const char *dir) +{ + char *argv[] = { "files", NULL, NULL }; + + cd_home(ctx); + argv[1] = (char *)dir; + + return run(argv); +} + int infix_datastore(kcontext_t *ctx) { - char *argv[] = { "files", "/cfg", NULL }; - const char *ds; + const char *word, *ds; - ds = kcontext_script(ctx); - if (!ds) - goto done; + if (is_path(ctx, &word)) + return complete_path(ctx, word); - if (!strcmp(ds, "src")) { + ds = kcontext_script(ctx); + if (ds && !strcmp(ds, "src")) { puts("factory-config"); puts("running-config"); puts("startup-config"); } - if (!strcmp(ds, "dst")) { + if (ds && !strcmp(ds, "dst")) { puts("running-config"); puts("startup-config"); } -done: - return run(argv); + puts("ftp://"); + puts("http://"); + puts("https://"); + puts("scp://"); + puts("sftp://"); + puts("tftp://"); + + complete_path(ctx, "/"); + + return list_files(ctx, "/cfg"); } int infix_erase(kcontext_t *ctx) { kpargv_t *pargv = kcontext_pargv(ctx); - const char *path; + const char *path, *user; char *argv[4]; path = kparg_value(kpargv_find(pargv, "file")); @@ -204,21 +249,68 @@ int infix_erase(kcontext_t *ctx) return -1; } - cd_home(ctx); + user = cd_home(ctx); argv[0] = "erase"; argv[1] = "-s"; argv[2] = (char *)path; argv[3] = NULL; + /* Run as the logged-in user, not root (klishd) */ + return run_as_user(user, argv); +} + +/* Complete a file system path, and offer the roots when nothing typed */ +int infix_path(kcontext_t *ctx) +{ + const char *word, *dir; + + if (is_path(ctx, &word)) + return complete_path(ctx, word); + + /* Nothing typed yet, show where a path can start */ + complete_path(ctx, "/"); + + dir = kcontext_script(ctx); + if (!dir) + return 0; + + return list_files(ctx, dir); +} + +int infix_rename(kcontext_t *ctx) +{ + kpargv_t *pargv = kcontext_pargv(ctx); + const char *from, *to; + char *argv[8]; + int i = 0; + + from = kparg_value(kpargv_find(pargv, "from")); + to = kparg_value(kpargv_find(pargv, "to")); + if (!from || !to) + return -1; + + /* Run as the logged-in user, not root (klishd) */ + argv[i++] = "doas"; + argv[i++] = "-u"; + argv[i++] = (char *)cd_home(ctx); + argv[i++] = "rename"; + argv[i++] = "-s"; + argv[i++] = (char *)from; + argv[i++] = (char *)to; + argv[i] = NULL; + return run(argv); } int infix_files(kcontext_t *ctx) { - const char *path; + const char *path, *word; char *argv[3]; + if (is_path(ctx, &word)) + return complete_path(ctx, word); + cd_home(ctx); path = kcontext_script(ctx); if (!path) { @@ -233,6 +325,27 @@ int infix_files(kcontext_t *ctx) return run(argv); } +/* List a directory, or the interesting locations when given no path */ +int infix_dir(kcontext_t *ctx) +{ + kpargv_t *pargv = kcontext_pargv(ctx); + const char *user; + kparg_t *parg; + char *argv[3]; + int i = 0; + + parg = kpargv_find(pargv, "path"); + user = cd_home(ctx); + + argv[i++] = "dir"; + if (parg) + argv[i++] = (char *)kparg_value(parg); + argv[i] = NULL; + + /* Run as the logged-in user, not root (klishd) */ + return run_as_user(user, argv); +} + int infix_ifaces(kcontext_t *ctx) { (void)ctx; @@ -737,8 +850,11 @@ int kplugin_infix_init(kcontext_t *ctx) kplugin_add_syms(plugin, ksym_new("boot_targets", infix_boot_targets)); kplugin_add_syms(plugin, ksym_new("copy", infix_copy)); kplugin_add_syms(plugin, ksym_new("datastore", infix_datastore)); + kplugin_add_syms(plugin, ksym_new("dir", infix_dir)); kplugin_add_syms(plugin, ksym_new("erase", infix_erase)); kplugin_add_syms(plugin, ksym_new("files", infix_files)); + kplugin_add_syms(plugin, ksym_new("path", infix_path)); + kplugin_add_syms(plugin, ksym_new("rename", infix_rename)); kplugin_add_syms(plugin, ksym_new("ifaces", infix_ifaces)); kplugin_add_syms(plugin, ksym_new("users", infix_users)); kplugin_add_syms(plugin, ksym_new("groups", infix_groups)); diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index 60b8f670a..7f2c9c21c 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -103,7 +103,14 @@ - /cfg + /cfg + + + + + + + @@ -260,11 +267,15 @@ - + - - dir $KLISH_PARAM_path - + + + + + + + @@ -508,6 +519,10 @@ echo "Public: $pub" + + show tftp + + diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 2275e3157..4fa241ade 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -3890,6 +3890,40 @@ def show_dhcp_server(json, stats): server.print() +def show_tftp(json): + data = json.get("infix-services:tftp", {}) + if not data.get("enabled"): + print("TFTP server not enabled.") + return + + def modified(ydate): + date = Date.from_yang(ydate) + return date.strftime("%Y-%m-%d %H:%M") if date else "" + + def size(num): + num = int(num or 0) + return format_memory_bytes(num) if num else "0" + + print(f"{'Root directory':<17}: {data.get('root', '')}") + print(f"{'Interfaces':<17}: {', '.join(data.get('interface', [])) or 'all'}") + print(f"{'Client directory':<17}: {data.get('client-directory', 'none')}") + print() + + files = data.get("files", {}).get("file", []) + if not files: + print("No files.") + return + + table = SimpleTable([ + Column('NAME', flexible=True), + Column('SIZE', 'right', formatter=size), + Column('MODIFIED', formatter=modified) + ]) + for entry in files: + table.row(entry.get("name"), entry.get("size"), entry.get("modified")) + table.print() + + def show_lldp(json): if not json.get("ieee802-dot1ab-lldp:lldp"): print("Error: No LLDP data available.") @@ -6175,6 +6209,7 @@ def main(): subparsers.add_parser('show-dhcp-server', help='Show DHCP server') \ .add_argument("-s", "--stats", action="store_true", help="Show server statistics") + subparsers.add_parser('show-tftp', help='Show TFTP server') subparsers.add_parser('show-container', help='Show containers table') subparsers.add_parser('show-container-detail', help='Show container details') \ .add_argument('name', help='Container name') @@ -6255,6 +6290,8 @@ def main(): show_bridge_stp(json_data) elif args.command == "show-dhcp-server": show_dhcp_server(json_data, args.stats) + elif args.command == "show-tftp": + show_tftp(json_data) elif args.command == "show-container": show_container(json_data) elif args.command == "show-container-detail": diff --git a/src/statd/python/yanger/__main__.py b/src/statd/python/yanger/__main__.py index c88d4648f..3a2799b47 100644 --- a/src/statd/python/yanger/__main__.py +++ b/src/statd/python/yanger/__main__.py @@ -108,6 +108,9 @@ def main(): elif model == 'infix-dhcp-server': from . import infix_dhcp_server yang_data = infix_dhcp_server.operational() + elif model == 'infix-services': + from . import infix_services + yang_data = infix_services.operational() elif model == 'ietf-system': from . import ietf_system yang_data = ietf_system.operational() diff --git a/src/statd/python/yanger/infix_services.py b/src/statd/python/yanger/infix_services.py new file mode 100644 index 000000000..d4b690b2b --- /dev/null +++ b/src/statd/python/yanger/infix_services.py @@ -0,0 +1,58 @@ +""" +Collect operational data for infix-services.yang +""" +from datetime import datetime, timezone + +from .host import HOST + +TFTP_CONF = "/etc/dnsmasq.d/tftp.conf" + + +def tftp_root(): + """TFTP root from the dnsmasq snippet written by confd, None if disabled""" + for line in HOST.read_multiline(TFTP_CONF, []): + if line.startswith("tftp-root="): + return line[len("tftp-root="):] + + return None + + +def tftp_files(root): + """List world-readable files below root, the ones dnsmasq serves + + Symlinks are followed, like the server does, and a name with a + newline in it is skipped. + """ + cmd = ("find", "-L", root, "-type", "f", "-perm", "-004", + "-exec", "stat", "-L", "-c", "%s %Y %n", "{}", "+") + prefix = root.rstrip("/") + "/" + files = [] + + for line in HOST.run_multiline(cmd, []): + fields = line.split(" ", 2) + if len(fields) != 3 or not fields[2].startswith(prefix): + continue + + size, mtime, path = fields + files.append({ + "name": path[len(prefix):], + "size": size, + "modified": datetime.fromtimestamp(int(mtime), timezone.utc).isoformat(), + }) + + return sorted(files, key=lambda f: f["name"]) + + +def operational(): + """Return operational status for infix-services""" + root = tftp_root() + if not root: + return {} + + return { + "infix-services:tftp": { + "files": { + "file": tftp_files(root) + } + } + } diff --git a/src/statd/statd.c b/src/statd/statd.c index 6509f601c..c3fbef096 100644 --- a/src/statd/statd.c +++ b/src/statd/statd.c @@ -51,6 +51,7 @@ #define XPATH_ROUTING_BFD XPATH_ROUTING_BASE "/bfd" #define XPATH_CONTAIN_BASE "/infix-containers:containers" #define XPATH_DHCP_SERVER_BASE "/infix-dhcp-server:dhcp-server" +#define XPATH_TFTP_FILES "/infix-services:tftp/files" #define XPATH_LLDP_BASE "/ieee802-dot1ab-lldp:lldp" #define XPATH_FIREWALL_BASE "/infix-firewall:firewall" #define XPATH_NTP_BASE "/ietf-ntp:ntp" @@ -459,6 +460,8 @@ static int subscribe_to_all(struct statd *statd) #endif if (subscribe(statd, "infix-dhcp-server", XPATH_DHCP_SERVER_BASE, sr_generic_cb)) return SR_ERR_INTERNAL; + if (subscribe(statd, "infix-services", XPATH_TFTP_FILES, sr_generic_cb)) + return SR_ERR_INTERNAL; if (subscribe(statd, "infix-firewall", XPATH_FIREWALL_BASE, sr_generic_cb)) return SR_ERR_INTERNAL; if (subscribe(statd, "ietf-ntp", XPATH_NTP_BASE, sr_generic_cb)) diff --git a/test/case/dhcp/Readme.adoc b/test/case/dhcp/Readme.adoc index fc29a9d47..3c0734542 100644 --- a/test/case/dhcp/Readme.adoc +++ b/test/case/dhcp/Readme.adoc @@ -14,6 +14,7 @@ Tests verifying DHCPv4/DHCPv6 client and server functionality: - Basic DHCPv4 server operation and lease assignment - DHCPv4 server with host-specific IP reservations - DHCPv4 server with multiple subnet configurations + - DHCPv4 server network boot parameters, per subnet and per host include::client_basic/Readme.adoc[] @@ -56,3 +57,7 @@ include::server_host/Readme.adoc[] <<< include::server_subnets/Readme.adoc[] + +<<< + +include::server_boot/Readme.adoc[] diff --git a/test/case/dhcp/dhcp_server.yaml b/test/case/dhcp/dhcp_server.yaml index 9a7812d60..3be0f7a7e 100644 --- a/test/case/dhcp/dhcp_server.yaml +++ b/test/case/dhcp/dhcp_server.yaml @@ -10,3 +10,6 @@ - name: DHCP Server Multiple Subnets case: server_subnets/test.py + +- name: DHCP Server Network Boot + case: server_boot/test.py diff --git a/test/case/dhcp/server_boot/Readme.adoc b/test/case/dhcp/server_boot/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/dhcp/server_boot/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/dhcp/server_boot/test.adoc b/test/case/dhcp/server_boot/test.adoc new file mode 100644 index 000000000..b07d2ea5e --- /dev/null +++ b/test/case/dhcp/server_boot/test.adoc @@ -0,0 +1,26 @@ +=== DHCP Server Network Boot + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/dhcp/server_boot] + +==== Description + +Verify that network boot parameters are handed out in the BOOTP header +fields, which BOOTP clients, U-Boot and PXE ROMs read, and that the most +specific scope wins: host over subnet over global. + +The DHCP client on the host records the server address (siaddr) and +boot file it receives in the lease. + +==== Topology + +image::topology.svg[DHCP Server Network Boot topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUT +. Configure DHCP server with global, subnet, and host boot parameters +. Verify pool client gets subnet boot file from this server +. Verify static host gets its own boot file +. Remove subnet and host boot, verify client falls back to global boot file and server + + diff --git a/test/case/dhcp/server_boot/test.py b/test/case/dhcp/server_boot/test.py new file mode 100755 index 000000000..d4f580cfe --- /dev/null +++ b/test/case/dhcp/server_boot/test.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""DHCP Server Network Boot + +Verify that network boot parameters are handed out in the BOOTP header +fields, which BOOTP clients, U-Boot and PXE ROMs read, and that the most +specific scope wins: host over subnet over global. + +The DHCP client on the host records the server address (siaddr) and +boot file it receives in the lease. + +""" +import infamy +import infamy.dhcp as dhcp +from infamy.util import until + +SERVER = "10.0.0.10" +OTHER_SERVER = "10.0.0.20" +GLOBAL_FILE = "global.itb" +SUBNET_FILE = "subnet.itb" +HOST_FILE = "host.itb" +HOST_MAC = "02:00:00:00:be:ef" +HOST_ADDR = "10.0.0.50" +SUBNET = "10.0.0.0/24" + +with infamy.Test() as test: + with test.step("Set up topology and attach to target DUT"): + env = infamy.Env() + target = env.attach("target", "mgmt") + _, hport = env.ltop.xlate("host", "data") + + with test.step("Configure DHCP server with global, subnet, and host boot parameters"): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": target["data"], + "enabled": True, + "ipv4": { + "address": [{ + "ip": SERVER, + "prefix-length": 24 + }] + } + }] + } + }, + "infix-dhcp-server": { + "dhcp-server": { + "boot": { + "file": GLOBAL_FILE, + "server-address": OTHER_SERVER + }, + "subnet": [{ + "subnet": SUBNET, + "boot": { + "file": SUBNET_FILE + }, + "pool": { + "start-address": "10.0.0.100", + "end-address": "10.0.0.100" + }, + "host": [{ + "address": HOST_ADDR, + "match": { + "mac-address": HOST_MAC + }, + "boot": { + "file": HOST_FILE + } + }] + }] + } + } + }) + + with infamy.IsolatedMacVlan(hport) as ns: + client = dhcp.Client(ns) + + with test.step("Verify pool client gets subnet boot file from this server"): + until(lambda: client.lease() == (SERVER, SUBNET_FILE)) + + with test.step("Verify static host gets its own boot file"): + ns.run(["ip", "link", "set", "iface", "address", HOST_MAC]) + until(lambda: client.lease() == (SERVER, HOST_FILE)) + + with test.step("Remove subnet and host boot, verify client falls back to global boot file and server"): + target.delete_xpath(f"/infix-dhcp-server:dhcp-server/subnet[subnet='{SUBNET}']/boot") + target.delete_xpath(f"/infix-dhcp-server:dhcp-server/subnet[subnet='{SUBNET}']" + f"/host[address='{HOST_ADDR}']/boot") + until(lambda: client.lease() == (OTHER_SERVER, GLOBAL_FILE)) + + test.succeed() diff --git a/test/case/dhcp/server_boot/topology.dot b/test/case/dhcp/server_boot/topology.dot new file mode 120000 index 000000000..4f53d15af --- /dev/null +++ b/test/case/dhcp/server_boot/topology.dot @@ -0,0 +1 @@ +../../../infamy/topologies/1x2.dot \ No newline at end of file diff --git a/test/case/dhcp/server_boot/topology.svg b/test/case/dhcp/server_boot/topology.svg new file mode 100644 index 000000000..ff3d246be --- /dev/null +++ b/test/case/dhcp/server_boot/topology.svg @@ -0,0 +1,42 @@ + + + + + + +1x2 + + + +host + +host + +mgmt + +data + + + +target + +mgmt + +data + +target + + + +host:mgmt--target:mgmt + + + + +host:data--target:data + + + + diff --git a/test/case/services/Readme.adoc b/test/case/services/Readme.adoc index ea49192a3..2b2c01e9b 100644 --- a/test/case/services/Readme.adoc +++ b/test/case/services/Readme.adoc @@ -10,6 +10,7 @@ Tests verifying network services configuration and operation: - LLDP IEEE group address forwarding behavior - SSH server configuration and access control - SSH public key authentication mechanisms + - TFTP server file serving and operational file listing include::mdns/mdns_enable_disable/Readme.adoc[] @@ -36,3 +37,7 @@ include::ssh/ssh_server_config/Readme.adoc[] <<< include::ssh/ssh_key_authentication/Readme.adoc[] + +<<< + +include::tftp/tftp_basic/Readme.adoc[] diff --git a/test/case/services/all.yaml b/test/case/services/all.yaml index 155de1b22..f1bee10f7 100644 --- a/test/case/services/all.yaml +++ b/test/case/services/all.yaml @@ -8,5 +8,8 @@ - name: SSH suite: ssh/all.yaml +- name: TFTP + suite: tftp/all.yaml + - name: Web UI suite: webui/all.yaml diff --git a/test/case/services/tftp/all.yaml b/test/case/services/tftp/all.yaml new file mode 100644 index 000000000..f118e0a79 --- /dev/null +++ b/test/case/services/tftp/all.yaml @@ -0,0 +1,3 @@ +--- +- name: TFTP Server + case: tftp_basic/test.py diff --git a/test/case/services/tftp/tftp_basic/Readme.adoc b/test/case/services/tftp/tftp_basic/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/services/tftp/tftp_basic/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/services/tftp/tftp_basic/test.adoc b/test/case/services/tftp/tftp_basic/test.adoc new file mode 100644 index 000000000..54aab9d31 --- /dev/null +++ b/test/case/services/tftp/tftp_basic/test.adoc @@ -0,0 +1,24 @@ +=== TFTP Server + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/services/tftp/tftp_basic] + +==== Description + +Verify that the TFTP server serves files uploaded to its root directory +with the copy command, and that the operational datastore lists them. +Files that are not world-readable must neither be served nor listed. + +==== Topology + +image::topology.svg[TFTP Server topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUT +. Configure target:data with 10.0.0.10/24 and enable TFTP server +. Upload a file to the TFTP root with copy, and place a private file beside it +. Verify operational datastore lists only the uploaded file +. Verify the uploaded file is served over TFTP +. Verify the private file is refused + + diff --git a/test/case/services/tftp/tftp_basic/test.py b/test/case/services/tftp/tftp_basic/test.py new file mode 100755 index 000000000..422d551a9 --- /dev/null +++ b/test/case/services/tftp/tftp_basic/test.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""TFTP Server + +Verify that the TFTP server serves files uploaded to its root directory +with the copy command, and that the operational datastore lists them. +Files that are not world-readable must neither be served nor listed. + +""" +import infamy +from infamy.util import parallel, until + +ADDR = "10.0.0.10" +ROOT = "/var/lib/tftpboot" +PUBLIC = "public.bin" +PRIVATE = "private.bin" +CONTENT = "netboot fallback image" + + +def fetch(ns, name): + """Fetch name over TFTP, return the file contents or None""" + res = ns.run(["curl", "-s", "-m", "2", f"tftp://{ADDR}/{name}"], + capture_output=True, text=True) + return res.stdout if res.returncode == 0 else None + + +with infamy.Test() as test: + with test.step("Set up topology and attach to target DUT"): + env = infamy.Env() + target, tgtssh = parallel(lambda: env.attach("target", "mgmt"), + lambda: env.attach("target", "mgmt", "ssh")) + _, hport = env.ltop.xlate("host", "data") + test.push_test_cleanup(lambda: tgtssh.runsh(f"sudo rm -f {ROOT}/{PUBLIC} {ROOT}/{PRIVATE}")) + + with test.step("Configure target:data with 10.0.0.10/24 and enable TFTP server"): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": target["data"], + "enabled": True, + "ipv4": { + "address": [{ + "ip": ADDR, + "prefix-length": 24 + }] + } + }] + } + }, + "infix-services": { + "tftp": { + "enabled": True + } + } + }) + + with test.step("Upload a file to the TFTP root with copy, and place a private file beside it"): + tgtssh.runsh(f""" + printf "{CONTENT}" > /tmp/{PUBLIC} + sudo copy -s -f /tmp/{PUBLIC} {ROOT}/{PUBLIC} + sudo sh -c 'umask 077; printf secret > {ROOT}/{PRIVATE}' + """) + + with test.step("Verify operational datastore lists only the uploaded file"): + def listed(): + files = target.get_data("/infix-services:tftp")["tftp"]["files"]["file"] + return {f["name"] for f in files} == {PUBLIC} + until(listed) + + with infamy.IsolatedMacVlan(hport) as ns: + with test.step("Verify the uploaded file is served over TFTP"): + ns.addip("10.0.0.1") + until(lambda: fetch(ns, PUBLIC) == CONTENT) + + with test.step("Verify the private file is refused"): + if fetch(ns, PRIVATE) is not None: + test.fail() + + test.succeed() diff --git a/test/case/services/tftp/tftp_basic/topology.dot b/test/case/services/tftp/tftp_basic/topology.dot new file mode 120000 index 000000000..09015e257 --- /dev/null +++ b/test/case/services/tftp/tftp_basic/topology.dot @@ -0,0 +1 @@ +../../../../infamy/topologies/1x2.dot \ No newline at end of file diff --git a/test/case/services/tftp/tftp_basic/topology.svg b/test/case/services/tftp/tftp_basic/topology.svg new file mode 100644 index 000000000..ff3d246be --- /dev/null +++ b/test/case/services/tftp/tftp_basic/topology.svg @@ -0,0 +1,42 @@ + + + + + + +1x2 + + + +host + +host + +mgmt + +data + + + +target + +mgmt + +data + +target + + + +host:mgmt--target:mgmt + + + + +host:data--target:data + + + + diff --git a/test/infamy/dhcp.py b/test/infamy/dhcp.py index 099471125..61c6ee173 100644 --- a/test/infamy/dhcp.py +++ b/test/infamy/dhcp.py @@ -72,6 +72,35 @@ def stop(self): self.process = None +class Client: + """One-shot DHCPv4 client (udhcpc), exposes the BOOTP header fields""" + SCRIPT = '#!/bin/sh\n[ "$1" = bound ] && echo "$siaddr $boot_file"\n' + + def __init__(self, netns, iface="iface"): + self.netns = netns + self.iface = iface + with tf.NamedTemporaryFile("w", suffix=".sh", delete=False) as fp: + fp.write(self.SCRIPT) + self.script = fp.name + os.chmod(self.script, 0o755) + + def __del__(self): + try: + os.unlink(self.script) + except OSError: + pass + + def lease(self): + """Request a lease, return (siaddr, boot_file) or None on failure""" + res = self.netns.run(["udhcpc", "-i", self.iface, "-f", "-q", "-n", + "-t", "3", "-T", "1", "-s", self.script], + capture_output=True, text=True) + if res.returncode: + return None + + return tuple(res.stdout.split()) + + class Server6Dnsmasq: """DHCPv6 server using dnsmasq""" diff --git a/test/infamy/restconf.py b/test/infamy/restconf.py index ba086e0f1..ad231c306 100644 --- a/test/infamy/restconf.py +++ b/test/infamy/restconf.py @@ -32,8 +32,13 @@ def xpath_to_uri(xpath, extra=None): uri_path = xpath if matches: for key, value in matches: + # A key value is one path segment, a slash in it has to be + # escaped, e.g. the prefix in subnet[subnet='10.0.0.0/24'], + # RFC 8040 sec. 3.5.3 + value_uri = value.replace('/', '%2F') + # replace [key=value] with =value - uri_path = re.sub(rf'\[{re.escape(key)}=["\']{re.escape(value)}["\']\]', f'={value}', uri_path) + uri_path = re.sub(rf'\[{re.escape(key)}=["\']{re.escape(value)}["\']\]', f'={value_uri}', uri_path) # Append extra if provided if extra is not None: