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
+
+
+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: