From f9e4840fbd6e65734a01dd04a16e2d54b6a7d78f Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 18 Sep 2026 07:56:35 +0200 Subject: [PATCH 01/16] .github: any label cancels the CI run of a new PR Adding a label fires a second pull_request event in the same concurrency group, cancelling the run from 'opened'. Only ci:main is meant to start a run, so the PR ends up with no CI at all. Signed-off-by: Joachim Wiberg --- .github/workflows/trigger.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/trigger.yml b/.github/workflows/trigger.yml index 223c1a8cb..bcfde069d 100644 --- a/.github/workflows/trigger.yml +++ b/.github/workflows/trigger.yml @@ -9,14 +9,16 @@ on: - ci-work workflow_dispatch: +# Label events other than 'ci:main' are skipped by check-trigger, keep them +# in a group of their own so they cannot cancel a run in progress. 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' && '-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. check-trigger: if: | From 879db589dccc3769087c4cb463fccee1de8aea23 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 18 Sep 2026 16:44:32 +0200 Subject: [PATCH 02/16] .github: skip build and test on a PR labeled ci:skip Changes that cannot affect the image, e.g. a ChangeLog fixup after another branch landed, should not spend an hour of CI. Adding the label to an open PR also stops a build already running. Signed-off-by: Joachim Wiberg --- .github/workflows/trigger.yml | 12 +++++++++--- doc/developers-guide.md | 8 ++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/trigger.yml b/.github/workflows/trigger.yml index bcfde069d..2d1827564 100644 --- a/.github/workflows/trigger.yml +++ b/.github/workflows/trigger.yml @@ -9,10 +9,12 @@ on: - ci-work workflow_dispatch: -# Label events other than 'ci:main' are skipped by check-trigger, keep them -# in a group of their own so they cannot cancel a run in progress. +# 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 }}${{ github.event.action == 'labeled' && github.event.label.name != 'ci:main' && '-label' || '' }} + 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: @@ -20,9 +22,13 @@ jobs: # 'labeled' events when the label is actually 'ci:main'. Concurrency control # 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/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 From 09a2adcc688106f95324ab14b790274028516e32 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 18 Sep 2026 06:59:23 +0200 Subject: [PATCH 03/16] bin: let copy and erase work outside /cfg Staging a file for another service, or copying a log off the system, was not possible. Only /cfg, /media and the user's home were accepted, a directory destination was refused, and the refusal said "no such file" about a file that was there. Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 3 + src/bin/copy.c | 35 +++++++----- src/bin/erase.c | 5 +- src/bin/files.c | 2 +- src/bin/util.c | 140 +++++++++++++++++++++++++++++++++-------------- src/bin/util.h | 2 + 6 files changed, 129 insertions(+), 58 deletions(-) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index d6d9fa15a..37d456967 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -43,6 +43,9 @@ All notable changes to the project are documented in this file. editor, show mesh peers on the WiFi and interface status pages, and add an editor section for access point roaming (802.11k/r/v, band steering, OKC). +- 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/src/bin/copy.c b/src/bin/copy.c index 2fcc8584a..7a788baaa 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -18,10 +18,11 @@ #include "util.h" -#define err(rc, fmt, args...) { fprintf(stderr, ERRMSG fmt ":%s\n", ##args, strerror(errno)); exit(rc); } -#define errx(rc, fmt, args...) { fprintf(stderr, ERRMSG fmt "\n", ##args); exit(rc); } -#define warnx(fmt, args...) fprintf(stderr, ERRMSG fmt "\n", ##args) -#define warn(fmt, args...) fprintf(stderr, ERRMSG fmt ":%s\n", ##args, strerror(errno)) +/* Like err(3), the message already says what went wrong, lead with who */ +#define err(rc, fmt, args...) { fprintf(stderr, "%s: " fmt ": %s\n", prognm, ##args, strerror(errno)); exit(rc); } +#define errx(rc, fmt, args...) { fprintf(stderr, "%s: " fmt "\n", prognm, ##args); exit(rc); } +#define warnx(fmt, args...) fprintf(stderr, "%s: " fmt "\n", prognm, ##args) +#define warn(fmt, args...) fprintf(stderr, "%s: " fmt ": %s\n", prognm, ##args, strerror(errno)) #define dbg(fmt, args...) if (debug) fprintf(stderr, DBGMSG fmt "\n", ##args) struct infix_ds { @@ -70,8 +71,8 @@ static const char *getuser(void) * return 1, otherwise 0. * * E.g., writing to /cfg/foo, where /cfg is owned by root:wheel, - * should result in the file being owned by $LOGNAME:wheel with - * 0660 perms for other users in same group. + * should result in the file being owned by $LOGNAME:wheel, with + * group access for other users in same group. */ static int in_group(const char *user, const char *fn, gid_t *gid) { @@ -128,6 +129,7 @@ static int in_group(const char *user, const char *fn, gid_t *gid) static void set_owner(const char *fn, const char *user) { gid_t gid = 9999; + mode_t mode; if (!fn) return; /* not an error, e.g., running-config is not a file */ @@ -146,8 +148,9 @@ static void set_owner(const char *fn, const char *user) * umask alone can't: the datastore export goes through a 0600 mkstemp * temp and cp(1) propagates that mode to the destination. */ - if (chmod(fn, 0660) && errno != EPERM) - warn("failed setting mode 0660 on %s", fn); + mode = path_mode(fn) ?: 0660; + if (chmod(fn, mode) && errno != EPERM) + warn("failed setting mode 0%o on %s", mode, fn); } static const char *infix_ds(const char *text, const struct infix_ds **ds) @@ -695,7 +698,13 @@ static int resolve_src(const char **src, const struct infix_ds **ds, char **path } if (!*path) { - warn("no such file %s", *src); + warnx("%s: no such file, or not an allowed path", *src); + return 1; + } + + /* Let cp(1) report only what it alone can find out */ + if (access(*path, R_OK)) { + warn("%s", *path); return 1; } @@ -703,7 +712,7 @@ static int resolve_src(const char **src, const struct infix_ds **ds, char **path return 0; } -static int resolve_dst(const char **dst, const struct infix_ds **ds, char **path) +static int resolve_dst(const char *src, const char **dst, const struct infix_ds **ds, char **path) { if (is_stdout(*dst) || is_uri(*dst)) return 0; @@ -721,11 +730,11 @@ static int resolve_dst(const char **dst, const struct infix_ds **ds, char **path *path = strdup((*ds)->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; } @@ -784,7 +793,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..d7c74035f 100644 --- a/src/bin/erase.c +++ b/src/bin/erase.c @@ -20,7 +20,8 @@ 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; } @@ -29,7 +30,7 @@ static int do_erase(const char *name) 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..2728afdf3 100644 --- a/src/bin/files.c +++ b/src/bin/files.c @@ -17,7 +17,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; } diff --git a/src/bin/util.c b/src/bin/util.c index 1226c3a6c..fd5ea0774 100644 --- a/src/bin/util.c +++ b/src/bin/util.c @@ -7,10 +7,12 @@ #include #include #include +#include #include #include "util.h" +#define CFG_DIR "/cfg/" static char rawgetch(void) { @@ -93,76 +95,130 @@ 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/", 0664, "" }, + { "/log/", 0664, "" }, + { "/var/tmp/", 0664, "" }, + { "/tmp/", 0664, "" }, +}; + +/* Match a location, both the directory itself and anything below it */ +static bool has_prefix(const char *path, const char *prefix) { - 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 (!prefix) + return false; + + len = strlen(prefix); + if (!strncmp(path, prefix, len)) + return true; + + /* Trailing slash in the table, the path may be without */ + return len && prefix[len - 1] == '/' && !path[len - 1] + && !strncmp(path, prefix, len - 1); +} + +static const struct location *path_lookup(const char *path) +{ + static struct location home = { NULL, 0660, "" }; + + home.prefix = getenv("HOME"); + if (has_prefix(path, home.prefix)) + return &home; + + for (size_t i = 0; i < NELEMS(allowed); i++) { + if (has_prefix(path, allowed[i].prefix)) + return &allowed[i]; } - return 0; + return NULL; +} + +mode_t path_mode(const char *path) +{ + const struct location *loc = path_lookup(path); + + return loc ? loc->mode : 0; } 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) { + goto err; } + } + + /* Directory destination, copy into it like cp(1) */ + if (template && fisdir(expanded)) { + 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..a70cd6d3a 100644 --- a/src/bin/util.h +++ b/src/bin/util.h @@ -3,6 +3,7 @@ #define BIN_UTIL_H_ #include #include +#include #include #define ERRMSG "Error: " @@ -15,6 +16,7 @@ int files (const char *path, const char *stripext); const char *basenm (const char *fn); int has_ext (const char *fn, const char *ext); +mode_t path_mode (const char *path); char *cfg_adjust (const char *path, const char *template, bool sanitize); #endif /* BIN_UTIL_H_ */ From cd2a2777637e9091304d8bcbd2fbc77698325d87 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 18 Sep 2026 11:06:21 +0200 Subject: [PATCH 04/16] bin: bash completion for copy, erase, rpc and show copy ended a completed directory with a space, so the path could not be typed further, show offered six of its twenty subcommands, and neither erase nor rpc had any. The files also sat in two places, installed two ways. Signed-off-by: Joachim Wiberg --- package/bin/bin.mk | 6 -- src/bin/.gitignore | 6 +- src/bin/Makefile.am | 3 +- src/bin/bash_completion.d/copy | 116 ++++++++++++++++++++++++++++++++ src/bin/bash_completion.d/erase | 24 +++++++ src/bin/bash_completion.d/rpc | 71 +++++++++++++++++++ src/bin/bash_completion.d/show | 4 +- src/bin/copy.bash | 93 ------------------------- 8 files changed, 219 insertions(+), 104 deletions(-) create mode 100644 src/bin/bash_completion.d/copy create mode 100644 src/bin/bash_completion.d/erase create mode 100644 src/bin/bash_completion.d/rpc delete mode 100644 src/bin/copy.bash 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/src/bin/.gitignore b/src/bin/.gitignore index 88967e2be..5a72dd9c6 100644 --- a/src/bin/.gitignore +++ b/src/bin/.gitignore @@ -1,8 +1,8 @@ *~ *.o -copy -erase -files +/copy +/erase +/files /aclocal.m4 /autom4te.cache/ diff --git a/src/bin/Makefile.am b/src/bin/Makefile.am index 8aab109d0..7b9085d41 100644 --- a/src/bin/Makefile.am +++ b/src/bin/Makefile.am @@ -6,7 +6,8 @@ sbin_SCRIPTS = support # 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/rpc bash_completion.d/show copy_SOURCES = copy.c util.c util.h copy_CPPFLAGS = -D_DEFAULT_SOURCE -D_GNU_SOURCE diff --git a/src/bin/bash_completion.d/copy b/src/bin/bash_completion.d/copy new file mode 100644 index 000000000..8b96d305a --- /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 -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/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 Date: Fri, 18 Sep 2026 17:46:33 +0200 Subject: [PATCH 05/16] klish: bump to 5880300 Completing a directory or a URI scheme ended the word with a space, so the path could not be typed any further. Also brings unambiguous command-name prefixes. Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 2 ++ package/klish/klish.hash | 2 +- package/klish/klish.mk | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 37d456967..24ae0b431 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -19,6 +19,8 @@ All notable changes to the project are documented in this file. ### 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 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 From c35a5c253057d2ea0028dc67588ee34131d23187 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 18 Sep 2026 14:30:22 +0200 Subject: [PATCH 06/16] cli: complete file paths Tab on "copy /", "remove /" or "dir /" gave nothing, the CLI has no shell to do it. Limited to the directories copy and erase accept. Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 2 + src/bin/files.c | 91 ++++++++++++++++++++++++++-- src/bin/util.c | 53 ++++++++++++---- src/bin/util.h | 1 + src/klish-plugin-infix/src/infix.c | 83 ++++++++++++++++++++++--- src/klish-plugin-infix/xml/infix.xml | 13 +++- 6 files changed, 214 insertions(+), 29 deletions(-) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 24ae0b431..ce6658e2c 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -35,6 +35,8 @@ 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 completes file system paths with Tab, for `copy`, `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 diff --git a/src/bin/files.c b/src/bin/files.c index 2728afdf3..5897fb64c 100644 --- a/src/bin/files.c +++ b/src/bin/files.c @@ -3,6 +3,8 @@ #include #include +#include +#include #include #include "util.h" @@ -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/util.c b/src/bin/util.c index fd5ea0774..e3cde04fc 100644 --- a/src/bin/util.c +++ b/src/bin/util.c @@ -112,21 +112,29 @@ static const struct location allowed[] = { { "/tmp/", 0664, "" }, }; -/* Match a location, both the directory itself and anything below it */ -static bool has_prefix(const char *path, const char *prefix) +/* + * 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) { size_t len; - if (!prefix) + if (!path || !dir) return false; - len = strlen(prefix); - if (!strncmp(path, prefix, len)) - return true; + 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] == '/'; - /* Trailing slash in the table, the path may be without */ - return len && prefix[len - 1] == '/' && !path[len - 1] - && !strncmp(path, prefix, len - 1); + return !path[len] || path[len] == '/'; } static const struct location *path_lookup(const char *path) @@ -134,17 +142,40 @@ static const struct location *path_lookup(const char *path) static struct location home = { NULL, 0660, "" }; home.prefix = getenv("HOME"); - if (has_prefix(path, home.prefix)) + if (path_within(path, home.prefix)) return &home; for (size_t i = 0; i < NELEMS(allowed); i++) { - if (has_prefix(path, allowed[i].prefix)) + if (path_within(path, allowed[i].prefix)) return &allowed[i]; } 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); diff --git a/src/bin/util.h b/src/bin/util.h index a70cd6d3a..434f3dbfe 100644 --- a/src/bin/util.h +++ b/src/bin/util.h @@ -17,6 +17,7 @@ int files (const char *path, const char *stripext); const char *basenm (const char *fn); 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/klish-plugin-infix/src/infix.c b/src/klish-plugin-infix/src/infix.c index 01bd48306..55db2255d 100644 --- a/src/klish-plugin-infix/src/infix.c +++ b/src/klish-plugin-infix/src/infix.c @@ -169,27 +169,68 @@ 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) @@ -214,11 +255,32 @@ int infix_erase(kcontext_t *ctx) return run(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_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) { @@ -739,6 +801,7 @@ int kplugin_infix_init(kcontext_t *ctx) kplugin_add_syms(plugin, ksym_new("datastore", infix_datastore)); 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("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..e1bd8287a 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -103,7 +103,14 @@ - /cfg + /cfg + + + + + + + @@ -260,10 +267,10 @@ - + - dir $KLISH_PARAM_path + dir "$KLISH_PARAM_path" From 4df12f4d9d7407afc84f61d8f2eb979575525b17 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 18 Sep 2026 17:15:22 +0200 Subject: [PATCH 07/16] bin: add rename, to keep a file instead of removing it There was no way to set a configuration aside from the CLI, only copy and remove, so the way to start from a clean slate was to remove the one file that holds the system's configuration. Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 8 +- src/bin/.gitignore | 1 + src/bin/Makefile.am | 11 ++- src/bin/bash_completion.d/rename | 24 +++++ src/bin/rename.c | 132 +++++++++++++++++++++++++++ src/bin/util.c | 6 +- src/bin/util.h | 1 + src/klish-plugin-infix/src/infix.c | 26 ++++++ src/klish-plugin-infix/xml/infix.xml | 6 ++ 9 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 src/bin/bash_completion.d/rename create mode 100644 src/bin/rename.c diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index ce6658e2c..a27d60584 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -35,8 +35,12 @@ 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 completes file system paths with Tab, for `copy`, `remove`, - and `dir`, limited to the directories those commands accept +- 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 diff --git a/src/bin/.gitignore b/src/bin/.gitignore index 5a72dd9c6..d9cd1745d 100644 --- a/src/bin/.gitignore +++ b/src/bin/.gitignore @@ -3,6 +3,7 @@ /copy /erase /files +/rename /aclocal.m4 /autom4te.cache/ diff --git a/src/bin/Makefile.am b/src/bin/Makefile.am index 7b9085d41..bf06c8d21 100644 --- a/src/bin/Makefile.am +++ b/src/bin/Makefile.am @@ -1,13 +1,14 @@ DISTCLEANFILES = *~ *.d ACLOCAL_AMFLAGS = -I m4 -bin_PROGRAMS = copy erase files +bin_PROGRAMS = copy erase files rename sbin_SCRIPTS = support # Bash completion bashcompdir = $(datadir)/bash-completion/completions dist_bashcomp_DATA = bash_completion.d/copy bash_completion.d/erase \ - bash_completion.d/rpc bash_completion.d/show + 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 @@ -21,6 +22,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/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/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/util.c b/src/bin/util.c index e3cde04fc..49ad0958e 100644 --- a/src/bin/util.c +++ b/src/bin/util.c @@ -221,8 +221,10 @@ char *cfg_adjust(const char *path, const char *template, bool sanitize) } } - /* Directory destination, copy into it like cp(1) */ - if (template && fisdir(expanded)) { + /* 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); diff --git a/src/bin/util.h b/src/bin/util.h index 434f3dbfe..7757fe191 100644 --- a/src/bin/util.h +++ b/src/bin/util.h @@ -15,6 +15,7 @@ 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); diff --git a/src/klish-plugin-infix/src/infix.c b/src/klish-plugin-infix/src/infix.c index 55db2255d..fdf438eac 100644 --- a/src/klish-plugin-infix/src/infix.c +++ b/src/klish-plugin-infix/src/infix.c @@ -273,6 +273,31 @@ int infix_path(kcontext_t *ctx) 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, *word; @@ -802,6 +827,7 @@ int kplugin_infix_init(kcontext_t *ctx) 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 e1bd8287a..a4970d4c9 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -274,6 +274,12 @@ + + + + + + From 7cd064c6f9d60b1bda3515269c9e1d26c2ca14ba Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 18 Sep 2026 17:16:25 +0200 Subject: [PATCH 08/16] bin: remove hides the startup configuration It is the one file most likely to be removed, and the only one whose removal changes what the system boots. Offer it, and say so before asking. Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 2 ++ src/bin/copy.c | 2 +- src/bin/erase.c | 5 +++++ src/bin/util.c | 2 -- src/bin/util.h | 3 +++ 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index a27d60584..eb9b2719e 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -35,6 +35,8 @@ 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 diff --git a/src/bin/copy.c b/src/bin/copy.c index 7a788baaa..7237e0f25 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -33,7 +33,7 @@ struct infix_ds { }; const struct infix_ds infix_config[] = { - { "startup-config", SR_DS_STARTUP, true, "/cfg/startup-config.cfg" }, + { "startup-config", SR_DS_STARTUP, true, STARTUP_CONFIG }, { "running-config", SR_DS_RUNNING, true, NULL }, /* { "candidate-config", SR_DS_CANDIDATE, true, NULL }, */ { "operational-state", SR_DS_OPERATIONAL, false, NULL }, diff --git a/src/bin/erase.c b/src/bin/erase.c index d7c74035f..9b13b6de7 100644 --- a/src/bin/erase.c +++ b/src/bin/erase.c @@ -26,6 +26,11 @@ static int do_erase(const char *name) 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; diff --git a/src/bin/util.c b/src/bin/util.c index 49ad0958e..73eaae077 100644 --- a/src/bin/util.c +++ b/src/bin/util.c @@ -12,8 +12,6 @@ #include "util.h" -#define CFG_DIR "/cfg/" - static char rawgetch(void) { struct termios saved, c; diff --git a/src/bin/util.h b/src/bin/util.h index 7757fe191..77da19985 100644 --- a/src/bin/util.h +++ b/src/bin/util.h @@ -6,6 +6,9 @@ #include #include +#define CFG_DIR "/cfg/" +#define STARTUP_CONFIG CFG_DIR "startup-config.cfg" + #define ERRMSG "Error: " #define DBGMSG "Debug: " #define INFMSG "Note: " From 6f75b2a208f8dafa2d8e1dcc294456c4426e3330 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 18 Sep 2026 07:20:14 +0200 Subject: [PATCH 09/16] confd: add TFTP server to infix-services Devices that netboot from the system, or fetch their configuration over TFTP, need a local server. Read-only, serving /var/lib/tftpboot by default, or a directory on USB media. Signed-off-by: Joachim Wiberg --- .../rootfs/usr/lib/tmpfiles.d/tftp.conf | 1 + src/confd/src/services.c | 43 ++++++++++++++++++ src/confd/yang/confd.inc | 2 +- src/confd/yang/confd/infix-services.yang | 44 +++++++++++++++++++ ...17.yang => infix-services@2026-09-18.yang} | 0 5 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 board/common/rootfs/usr/lib/tmpfiles.d/tftp.conf rename src/confd/yang/confd/{infix-services@2026-06-17.yang => infix-services@2026-09-18.yang} (100%) 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/src/confd/src/services.c b/src/confd/src/services.c index 7006ea144..4a05faa41 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,44 @@ static int web_change(sr_session_ctx_t *session, struct lyd_node *config, struct return put(cfg); } +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; + } + + 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 +832,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 62ba37b9d..4f14e4a41 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -42,7 +42,7 @@ MODULES=( "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-08.yang" "ieee802-ethernet-interface@2025-09-10.yang" "ieee802-ethernet-phy-type@2025-09-10.yang" diff --git a/src/confd/yang/confd/infix-services.yang b/src/confd/yang/confd/infix-services.yang index c23651ec5..b24f1127e 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,44 @@ 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/[^/.][^/]*)(/[^/.][^/]*)*'; + } + 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"; + } + } + } + } } 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 From db56f368e788cec0fcdbfb8713253e598e9274b9 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 18 Sep 2026 07:27:10 +0200 Subject: [PATCH 10/16] statd: list the files a TFTP server serves Operators need to see what the server hands out, in particular when a device fails to boot from it. Signed-off-by: Joachim Wiberg --- src/confd/yang/confd/infix-services.yang | 23 +++++++++++ src/statd/python/yanger/__main__.py | 3 ++ src/statd/python/yanger/infix_services.py | 49 +++++++++++++++++++++++ src/statd/statd.c | 3 ++ 4 files changed, 78 insertions(+) create mode 100644 src/statd/python/yanger/infix_services.py diff --git a/src/confd/yang/confd/infix-services.yang b/src/confd/yang/confd/infix-services.yang index b24f1127e..a48cf6dfe 100644 --- a/src/confd/yang/confd/infix-services.yang +++ b/src/confd/yang/confd/infix-services.yang @@ -368,5 +368,28 @@ module infix-services { } } } + + 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/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..3fa32460b --- /dev/null +++ b/src/statd/python/yanger/infix_services.py @@ -0,0 +1,49 @@ +""" +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""" + cmd = ("find", root, "-type", "f", "-perm", "-004", + "-exec", "stat", "-c", "%s %Y %n", "{}", "+") + files = [] + + for line in HOST.run_multiline(cmd, []): + size, mtime, path = line.split(" ", 2) + files.append({ + "name": path[len(root) + 1:], + "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 e338725f0..d607af13e 100644 --- a/src/statd/statd.c +++ b/src/statd/statd.c @@ -50,6 +50,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" @@ -454,6 +455,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)) From 60a176cb4ce06cefcd7c4779d4e6c76438c7f8c0 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 18 Sep 2026 07:28:16 +0200 Subject: [PATCH 11/16] confd: hand out network boot parameters from the DHCP server Devices that netboot as a fallback read the boot file and server address from the BOOTP header fields, which the option list cannot set. Signed-off-by: Joachim Wiberg --- src/confd/src/dhcp-server.c | 54 ++++++++++---- src/confd/yang/confd.inc | 2 +- src/confd/yang/confd/infix-dhcp-server.yang | 71 ++++++++++++++++--- ...yang => infix-dhcp-server@2026-09-18.yang} | 0 4 files changed, 105 insertions(+), 22 deletions(-) rename src/confd/yang/confd/{infix-dhcp-server@2025-10-28.yang => infix-dhcp-server@2026-09-18.yang} (100%) 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/yang/confd.inc b/src/confd/yang/confd.inc index 4f14e4a41..189f07445 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -37,7 +37,7 @@ 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" 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 From 14240820e06ea2b20e07825344c9e858f8a9e4d5 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 18 Sep 2026 07:32:37 +0200 Subject: [PATCH 12/16] cli: show the TFTP server and the files it serves dir listed three of the places copy accepts, and with no argument it stopped after the first one. Signed-off-by: Joachim Wiberg --- board/common/rootfs/usr/bin/dir | 43 ++++++++++++++++------- src/bin/show/__init__.py | 15 +++++++- src/klish-plugin-infix/xml/infix.xml | 4 +++ src/statd/python/cli_pretty/cli_pretty.py | 37 +++++++++++++++++++ 4 files changed, 85 insertions(+), 14 deletions(-) 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/src/bin/show/__init__.py b/src/bin/show/__init__.py index 4d240c4a9..9f0a56b4f 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: @@ -759,7 +771,8 @@ def execute_command(command: str, args: List[str]): 'services': services, 'software': software, 'stp': stp, - 'system': system + 'system': system, + 'tftp': tftp } if command in command_mapping: diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index a4970d4c9..a70135761 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -521,6 +521,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 c6b254b95..34023f4c4 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -3881,6 +3881,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.") @@ -6088,6 +6122,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') @@ -6166,6 +6201,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": From 64c32b0b76380e60836b7f51a40b2c8471e929fd Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 20 Sep 2026 22:25:36 +0200 Subject: [PATCH 13/16] test: infamy: a list key holding a slash breaks the RESTCONF URL The key becomes one path segment, so a prefix like 10.0.0.0/24 splits the path and the server answers 400. Only a test addressing a list entry by such a key hits it, and only when the pseudo-random transport picks RESTCONF, which is why it passes on one rig and fails on another. Signed-off-by: Joachim Wiberg --- test/infamy/restconf.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/infamy/restconf.py b/test/infamy/restconf.py index ad004a8dc..5a9e3bc91 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: From ee6492a7859764cf5fe454f35f35d62437e45970 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 18 Sep 2026 07:38:00 +0200 Subject: [PATCH 14/16] test: TFTP server and DHCP network boot Cover the TFTP server end to end, and the BOOTP header fields a netbooting client sees, including which scope wins. Signed-off-by: Joachim Wiberg --- test/case/dhcp/Readme.adoc | 5 + test/case/dhcp/dhcp_server.yaml | 3 + test/case/dhcp/server_boot/Readme.adoc | 1 + test/case/dhcp/server_boot/test.adoc | 26 ++++++ test/case/dhcp/server_boot/test.py | 92 +++++++++++++++++++ test/case/dhcp/server_boot/topology.dot | 1 + test/case/dhcp/server_boot/topology.svg | 42 +++++++++ test/case/services/Readme.adoc | 5 + test/case/services/all.yaml | 3 + test/case/services/tftp/all.yaml | 3 + .../case/services/tftp/tftp_basic/Readme.adoc | 1 + test/case/services/tftp/tftp_basic/test.adoc | 24 +++++ test/case/services/tftp/tftp_basic/test.py | 79 ++++++++++++++++ .../services/tftp/tftp_basic/topology.dot | 1 + .../services/tftp/tftp_basic/topology.svg | 42 +++++++++ test/infamy/dhcp.py | 29 ++++++ 16 files changed, 357 insertions(+) create mode 120000 test/case/dhcp/server_boot/Readme.adoc create mode 100644 test/case/dhcp/server_boot/test.adoc create mode 100755 test/case/dhcp/server_boot/test.py create mode 120000 test/case/dhcp/server_boot/topology.dot create mode 100644 test/case/dhcp/server_boot/topology.svg create mode 100644 test/case/services/tftp/all.yaml create mode 120000 test/case/services/tftp/tftp_basic/Readme.adoc create mode 100644 test/case/services/tftp/tftp_basic/test.adoc create mode 100755 test/case/services/tftp/tftp_basic/test.py create mode 120000 test/case/services/tftp/tftp_basic/topology.dot create mode 100644 test/case/services/tftp/tftp_basic/topology.svg 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""" From 61ce516f9e29b155a1d735cd8dab96fa24e505e4 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 18 Sep 2026 07:38:00 +0200 Subject: [PATCH 15/16] doc: TFTP server and DHCP network boot Where files live, how to get them there, per-client directories, and the scope precedence for boot parameters. Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 7 ++++ doc/README.md | 1 + doc/dhcp.md | 33 ++++++++++++++++ doc/tftp.md | 100 +++++++++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 5 files changed, 142 insertions(+) create mode 100644 doc/tftp.md diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index eb9b2719e..4823e838e 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -53,6 +53,13 @@ All notable changes to the project are documented in this file. editor, show mesh peers on the WiFi and interface status pages, and add an editor section for access point roaming (802.11k/r/v, band steering, OKC). +- 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 the 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` 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/dhcp.md b/doc/dhcp.md index 6898ebd91..1b1443857 100644 --- a/doc/dhcp.md +++ b/doc/dhcp.md @@ -140,6 +140,39 @@ 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. + +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..71d967ed0 --- /dev/null +++ b/doc/tftp.md @@ -0,0 +1,100 @@ +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. + + +## 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 550a8b3f3..9f36b35da 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -49,6 +49,7 @@ nav: - DHCP Server: dhcp.md - NTP Server: ntp.md - PTP (IEEE 1588/802.1AS): ptp.md + - TFTP Server: tftp.md - System: - Boot Procedure: boot.md - Configuration: system.md From 629fa871a3fa65624ffc4af4c5ef01866375ca09 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 18 Sep 2026 16:07:17 +0200 Subject: [PATCH 16/16] doc: update nav bar text-editor.md -> edit.md Regression introduced in 0b026fae4c36305d3d83dc5530c3e77acced5a57 Signed-off-by: Joachim Wiberg --- mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 9f36b35da..154292b5c 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: