git: b42e852e89cb - main - pkg-serve(8): serve pkg repositories over TCP via inetd (8)
- Go to: [ bottom of page ] [ top of archives ] [ this month ]
Date: Fri, 20 Mar 2026 12:33:28 UTC
The branch main has been updated by bapt:
URL: https://cgit.FreeBSD.org/src/commit/?id=b42e852e89cb04cceb6e0226d6a08cab13fb6e90
commit b42e852e89cb04cceb6e0226d6a08cab13fb6e90
Author: Baptiste Daroussin <bapt@FreeBSD.org>
AuthorDate: 2026-03-17 11:02:28 +0000
Commit: Baptiste Daroussin <bapt@FreeBSD.org>
CommitDate: 2026-03-20 12:29:48 +0000
pkg-serve(8): serve pkg repositories over TCP via inetd (8)
Reviewed by: manu, bdrewery (previous version)
Differential Revision: https://reviews.freebsd.org/D55895
---
etc/mtree/BSD.tests.dist | 2 +
libexec/Makefile | 4 +
libexec/pkg-serve/Makefile | 9 ++
libexec/pkg-serve/pkg-serve.8 | 107 ++++++++++++++
libexec/pkg-serve/pkg-serve.c | 180 +++++++++++++++++++++++
libexec/pkg-serve/tests/Makefile | 5 +
libexec/pkg-serve/tests/pkg_serve_test.sh | 230 ++++++++++++++++++++++++++++++
share/mk/src.opts.mk | 1 +
tools/build/mk/OptionalObsoleteFiles.inc | 5 +
tools/build/options/WITHOUT_PKGSERVE | 2 +
10 files changed, 545 insertions(+)
diff --git a/etc/mtree/BSD.tests.dist b/etc/mtree/BSD.tests.dist
index 4fa35d1a17d0..bb1e40e69ba0 100644
--- a/etc/mtree/BSD.tests.dist
+++ b/etc/mtree/BSD.tests.dist
@@ -477,6 +477,8 @@
..
nuageinit
..
+ pkg-serve
+ ..
rc
..
rtld-elf
diff --git a/libexec/Makefile b/libexec/Makefile
index 180dd10b5d29..bfcd55b255c7 100644
--- a/libexec/Makefile
+++ b/libexec/Makefile
@@ -65,6 +65,10 @@ _dma= dma
_hyperv+= hyperv
.endif
+.if ${MK_PKGSERVE} != "no"
+_pkgserve= pkg-serve
+.endif
+
.if ${MK_NIS} != "no"
_mknetid= mknetid
_ypxfr= ypxfr
diff --git a/libexec/pkg-serve/Makefile b/libexec/pkg-serve/Makefile
new file mode 100644
index 000000000000..3ac6bf34be14
--- /dev/null
+++ b/libexec/pkg-serve/Makefile
@@ -0,0 +1,9 @@
+.include <src.opts.mk>
+
+PROG= pkg-serve
+MAN= pkg-serve.8
+BINDIR= /usr/libexec
+
+SUBDIR.${MK_TESTS}+= tests
+
+.include <bsd.prog.mk>
diff --git a/libexec/pkg-serve/pkg-serve.8 b/libexec/pkg-serve/pkg-serve.8
new file mode 100644
index 000000000000..b51daff029db
--- /dev/null
+++ b/libexec/pkg-serve/pkg-serve.8
@@ -0,0 +1,107 @@
+.\" Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+.\"
+.\" SPDX-License-Identifier: BSD-2-Clause
+.\"
+.Dd March 17, 2026
+.Dt PKG-SERVE 8
+.Os
+.Sh NAME
+.Nm pkg-serve
+.Nd serve pkg repositories over TCP via inetd
+.Sh SYNOPSIS
+.Nm
+.Ar basedir
+.Sh DESCRIPTION
+The
+.Nm
+utility serves
+.Xr pkg 8
+repositories using the pkg TCP protocol.
+It is designed to be invoked by
+.Xr inetd 8
+and communicates via standard input and output.
+.Pp
+The
+.Ar basedir
+argument specifies the root directory containing the package repositories.
+All file requests are resolved relative to this directory.
+.Pp
+On startup,
+.Nm
+enters a Capsicum sandbox, restricting filesystem access to
+.Ar basedir .
+.Sh PROTOCOL
+The protocol is line-oriented.
+Upon connection, the server sends a greeting:
+.Bd -literal -offset indent
+ok: pkg-serve <version>
+.Ed
+.Pp
+The client may then issue commands:
+.Bl -tag -width "get file age"
+.It Ic get Ar file age
+Request a file.
+If the file's modification time is newer than
+.Ar age
+(a Unix timestamp), the server responds with:
+.Bd -literal -offset indent
+ok: <size>
+.Ed
+.Pp
+followed by
+.Ar size
+bytes of file data.
+If the file has not been modified, the server responds with:
+.Bd -literal -offset indent
+ok: 0
+.Ed
+.Pp
+On error, the server responds with:
+.Bd -literal -offset indent
+ko: <error message>
+.Ed
+.It Ic quit
+Close the connection.
+.El
+.Sh INETD CONFIGURATION
+Add the following line to
+.Xr inetd.conf 5 :
+.Bd -literal -offset indent
+pkg stream tcp nowait nobody /usr/libexec/pkg-serve pkg-serve /usr/local/poudriere/data/packages
+.Ed
+.Pp
+And define the service in
+.Xr services 5 :
+.Bd -literal -offset indent
+pkg 62000/tcp
+.Ed
+.Sh REPOSITORY CONFIGURATION
+On the client side, configure a repository in
+.Pa /usr/local/etc/pkg/repos/myrepo.conf
+to use the
+.Ic tcp://
+scheme:
+.Bd -literal -offset indent
+myrepo: {
+ url: "tcp://pkgserver.example.com:62000/myrepo",
+}
+.Ed
+.Pp
+The path component of the URL is resolved relative to the
+.Ar basedir
+given to
+.Nm .
+For example, if
+.Nm
+is started with
+.Pa /usr/local/poudriere/data/packages
+as
+.Ar basedir ,
+the above configuration will serve files from
+.Pa /usr/local/poudriere/data/packages/myrepo/ .
+.Sh SEE ALSO
+.Xr inetd 8 ,
+.Xr inetd.conf 5 ,
+.Xr pkg 8
+.Sh AUTHORS
+.An Baptiste Daroussin Aq Mt bapt@FreeBSD.org
diff --git a/libexec/pkg-serve/pkg-serve.c b/libexec/pkg-serve/pkg-serve.c
new file mode 100644
index 000000000000..56770ef37f88
--- /dev/null
+++ b/libexec/pkg-serve/pkg-serve.c
@@ -0,0 +1,180 @@
+/*-
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+ */
+
+/*
+ * Speaks the same protocol as "pkg ssh" (see pkg-ssh(8)):
+ * -> ok: pkg-serve <version>
+ * <- get <file> <mtime>
+ * -> ok: <size>\n<data> or ok: 0\n or ko: <error>\n
+ * <- quit
+ */
+
+#include <sys/capsicum.h>
+#include <sys/stat.h>
+
+#include <ctype.h>
+#include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <inttypes.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#define VERSION "0.1"
+#define BUFSZ 32768
+
+static void
+usage(void)
+{
+ fprintf(stderr, "usage: pkg-serve basedir\n");
+ exit(EXIT_FAILURE);
+}
+
+int
+main(int argc, char *argv[])
+{
+ struct stat st;
+ cap_rights_t rights;
+ char *line = NULL;
+ char *file, *age;
+ size_t linecap = 0, r, toread;
+ ssize_t linelen;
+ off_t remaining;
+ time_t mtime;
+ char *end;
+ int fd, ffd;
+ char buf[BUFSZ];
+ const char *basedir;
+
+ if (argc != 2)
+ usage();
+
+ basedir = argv[1];
+
+ if ((fd = open(basedir, O_DIRECTORY | O_RDONLY | O_CLOEXEC)) < 0)
+ err(EXIT_FAILURE, "open(%s)", basedir);
+
+ cap_rights_init(&rights, CAP_READ, CAP_FSTATAT, CAP_LOOKUP,
+ CAP_FCNTL);
+ if (cap_rights_limit(fd, &rights) < 0 && errno != ENOSYS)
+ err(EXIT_FAILURE, "cap_rights_limit");
+
+ if (cap_enter() < 0 && errno != ENOSYS)
+ err(EXIT_FAILURE, "cap_enter");
+
+ printf("ok: pkg-serve " VERSION "\n");
+ fflush(stdout);
+
+ while ((linelen = getline(&line, &linecap, stdin)) > 0) {
+ /* trim newline */
+ if (linelen > 0 && line[linelen - 1] == '\n')
+ line[--linelen] = '\0';
+
+ if (linelen == 0)
+ continue;
+
+ if (strcmp(line, "quit") == 0)
+ break;
+
+ if (strncmp(line, "get ", 4) != 0) {
+ printf("ko: unknown command '%s'\n", line);
+ fflush(stdout);
+ continue;
+ }
+
+ file = line + 4;
+
+ if (*file == '\0') {
+ printf("ko: bad command get, expecting 'get file age'\n");
+ fflush(stdout);
+ continue;
+ }
+
+ /* skip leading slash */
+ if (*file == '/')
+ file++;
+
+ /* find the age argument */
+ age = file;
+ while (*age != '\0' && !isspace((unsigned char)*age))
+ age++;
+
+ if (*age == '\0') {
+ printf("ko: bad command get, expecting 'get file age'\n");
+ fflush(stdout);
+ continue;
+ }
+
+ *age++ = '\0';
+
+ /* skip whitespace */
+ while (isspace((unsigned char)*age))
+ age++;
+
+ if (*age == '\0') {
+ printf("ko: bad command get, expecting 'get file age'\n");
+ fflush(stdout);
+ continue;
+ }
+
+ errno = 0;
+ mtime = (time_t)strtoimax(age, &end, 10);
+ if (errno != 0 || *end != '\0' || end == age) {
+ printf("ko: bad number %s\n", age);
+ fflush(stdout);
+ continue;
+ }
+
+ if (fstatat(fd, file, &st, AT_RESOLVE_BENEATH) == -1) {
+ printf("ko: file not found\n");
+ fflush(stdout);
+ continue;
+ }
+
+ if (!S_ISREG(st.st_mode)) {
+ printf("ko: not a file\n");
+ fflush(stdout);
+ continue;
+ }
+
+ if (st.st_mtime <= mtime) {
+ printf("ok: 0\n");
+ fflush(stdout);
+ continue;
+ }
+
+ if ((ffd = openat(fd, file, O_RDONLY | O_RESOLVE_BENEATH)) == -1) {
+ printf("ko: file not found\n");
+ fflush(stdout);
+ continue;
+ }
+
+ printf("ok: %" PRIdMAX "\n", (intmax_t)st.st_size);
+ fflush(stdout);
+
+ remaining = st.st_size;
+ while (remaining > 0) {
+ toread = sizeof(buf);
+ if ((off_t)toread > remaining)
+ toread = (size_t)remaining;
+ r = read(ffd, buf, toread);
+ if (r <= 0)
+ break;
+ if (fwrite(buf, 1, r, stdout) != r)
+ break;
+ remaining -= r;
+ }
+ close(ffd);
+ if (remaining > 0)
+ errx(EXIT_FAILURE, "%s: file truncated during transfer",
+ file);
+ fflush(stdout);
+ }
+
+ return (EXIT_SUCCESS);
+}
diff --git a/libexec/pkg-serve/tests/Makefile b/libexec/pkg-serve/tests/Makefile
new file mode 100644
index 000000000000..9c62cf855f83
--- /dev/null
+++ b/libexec/pkg-serve/tests/Makefile
@@ -0,0 +1,5 @@
+PACKAGE= tests
+
+ATF_TESTS_SH= pkg_serve_test
+
+.include <bsd.test.mk>
diff --git a/libexec/pkg-serve/tests/pkg_serve_test.sh b/libexec/pkg-serve/tests/pkg_serve_test.sh
new file mode 100644
index 000000000000..fc9cf2101369
--- /dev/null
+++ b/libexec/pkg-serve/tests/pkg_serve_test.sh
@@ -0,0 +1,230 @@
+#-
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2026 Baptiste Daroussin <bapt@FreeBSD.org>
+
+PKG_SERVE="${PKG_SERVE:-/usr/libexec/pkg-serve}"
+
+serve()
+{
+ printf "$1" | "${PKG_SERVE}" "$2"
+}
+
+check_output()
+{
+ local pattern="$1" ; shift
+ output=$(serve "$@")
+ case "$output" in
+ *${pattern}*)
+ return 0
+ ;;
+ *)
+ echo "Expected pattern: ${pattern}"
+ echo "Got: ${output}"
+ return 1
+ ;;
+ esac
+}
+
+atf_test_case greeting
+greeting_head()
+{
+ atf_set "descr" "Server sends greeting on connect"
+}
+greeting_body()
+{
+ mkdir repo
+ check_output "ok: pkg-serve " "quit\n" repo ||
+ atf_fail "greeting not found"
+}
+
+atf_test_case unknown_command
+unknown_command_head()
+{
+ atf_set "descr" "Unknown commands get ko response"
+}
+unknown_command_body()
+{
+ mkdir repo
+ check_output "ko: unknown command 'plop'" "plop\nquit\n" repo ||
+ atf_fail "expected ko for unknown command"
+}
+
+atf_test_case get_missing_file
+get_missing_file_head()
+{
+ atf_set "descr" "Requesting a missing file returns ko"
+}
+get_missing_file_body()
+{
+ mkdir repo
+ check_output "ko: file not found" "get nonexistent.pkg 0\nquit\n" repo ||
+ atf_fail "expected file not found"
+}
+
+atf_test_case get_file
+get_file_head()
+{
+ atf_set "descr" "Requesting an existing file returns its content"
+}
+get_file_body()
+{
+ mkdir repo
+ echo "testcontent" > repo/test.pkg
+ output=$(serve "get test.pkg 0\nquit\n" repo)
+ echo "$output" | grep -q "ok: 12" ||
+ atf_fail "expected ok: 12, got: ${output}"
+ echo "$output" | grep -q "testcontent" ||
+ atf_fail "expected testcontent in output"
+}
+
+atf_test_case get_file_leading_slash
+get_file_leading_slash_head()
+{
+ atf_set "descr" "Leading slash in path is stripped"
+}
+get_file_leading_slash_body()
+{
+ mkdir repo
+ echo "testcontent" > repo/test.pkg
+ check_output "ok: 12" "get /test.pkg 0\nquit\n" repo ||
+ atf_fail "leading slash not stripped"
+}
+
+atf_test_case get_file_uptodate
+get_file_uptodate_head()
+{
+ atf_set "descr" "File with old mtime returns ok: 0"
+}
+get_file_uptodate_body()
+{
+ mkdir repo
+ echo "testcontent" > repo/test.pkg
+ check_output "ok: 0" "get test.pkg 9999999999\nquit\n" repo ||
+ atf_fail "expected ok: 0 for up-to-date file"
+}
+
+atf_test_case get_directory
+get_directory_head()
+{
+ atf_set "descr" "Requesting a directory returns ko"
+}
+get_directory_body()
+{
+ mkdir -p repo/subdir
+ check_output "ko: not a file" "get subdir 0\nquit\n" repo ||
+ atf_fail "expected not a file"
+}
+
+atf_test_case get_missing_age
+get_missing_age_head()
+{
+ atf_set "descr" "get without age argument returns error"
+}
+get_missing_age_body()
+{
+ mkdir repo
+ check_output "ko: bad command get" "get test.pkg\nquit\n" repo ||
+ atf_fail "expected bad command get"
+}
+
+atf_test_case get_bad_age
+get_bad_age_head()
+{
+ atf_set "descr" "get with non-numeric age returns error"
+}
+get_bad_age_body()
+{
+ mkdir repo
+ check_output "ko: bad number" "get test.pkg notanumber\nquit\n" repo ||
+ atf_fail "expected bad number"
+}
+
+atf_test_case get_empty_arg
+get_empty_arg_head()
+{
+ atf_set "descr" "get with no arguments returns error"
+}
+get_empty_arg_body()
+{
+ mkdir repo
+ check_output "ko: bad command get" "get \nquit\n" repo ||
+ atf_fail "expected bad command get"
+}
+
+atf_test_case path_traversal
+path_traversal_head()
+{
+ atf_set "descr" "Path traversal with .. is rejected"
+}
+path_traversal_body()
+{
+ mkdir repo
+ check_output "ko: file not found" \
+ "get ../etc/passwd 0\nquit\n" repo ||
+ atf_fail "path traversal not rejected"
+}
+
+atf_test_case get_subdir_file
+get_subdir_file_head()
+{
+ atf_set "descr" "Files in subdirectories are served"
+}
+get_subdir_file_body()
+{
+ mkdir -p repo/sub
+ echo "subcontent" > repo/sub/file.pkg
+ output=$(serve "get sub/file.pkg 0\nquit\n" repo)
+ echo "$output" | grep -q "ok: 11" ||
+ atf_fail "expected ok: 11, got: ${output}"
+ echo "$output" | grep -q "subcontent" ||
+ atf_fail "expected subcontent in output"
+}
+
+atf_test_case multiple_gets
+multiple_gets_head()
+{
+ atf_set "descr" "Multiple get commands in one session"
+}
+multiple_gets_body()
+{
+ mkdir repo
+ echo "aaa" > repo/a.pkg
+ echo "bbb" > repo/b.pkg
+ output=$(serve "get a.pkg 0\nget b.pkg 0\nquit\n" repo)
+ echo "$output" | grep -q "ok: 4" ||
+ atf_fail "expected ok: 4 for a.pkg"
+ echo "$output" | grep -q "aaa" ||
+ atf_fail "expected content of a.pkg"
+ echo "$output" | grep -q "bbb" ||
+ atf_fail "expected content of b.pkg"
+}
+
+atf_test_case bad_basedir
+bad_basedir_head()
+{
+ atf_set "descr" "Non-existent basedir causes exit failure"
+}
+bad_basedir_body()
+{
+ atf_check -s not-exit:0 -e match:"open" \
+ "${PKG_SERVE}" /nonexistent/path
+}
+
+atf_init_test_cases()
+{
+ atf_add_test_case greeting
+ atf_add_test_case unknown_command
+ atf_add_test_case get_missing_file
+ atf_add_test_case get_file
+ atf_add_test_case get_file_leading_slash
+ atf_add_test_case get_file_uptodate
+ atf_add_test_case get_directory
+ atf_add_test_case get_missing_age
+ atf_add_test_case get_bad_age
+ atf_add_test_case get_empty_arg
+ atf_add_test_case path_traversal
+ atf_add_test_case get_subdir_file
+ atf_add_test_case multiple_gets
+ atf_add_test_case bad_basedir
+}
diff --git a/share/mk/src.opts.mk b/share/mk/src.opts.mk
index fa91c537188d..5a5bdd16298e 100644
--- a/share/mk/src.opts.mk
+++ b/share/mk/src.opts.mk
@@ -156,6 +156,7 @@ __DEFAULT_YES_OPTIONS = \
PAM \
PF \
PKGBOOTSTRAP \
+ PKGSERVE \
PMC \
PPP \
PTHREADS_ASSERTIONS \
diff --git a/tools/build/mk/OptionalObsoleteFiles.inc b/tools/build/mk/OptionalObsoleteFiles.inc
index fd25359c7c0b..3a45c7c9a01c 100644
--- a/tools/build/mk/OptionalObsoleteFiles.inc
+++ b/tools/build/mk/OptionalObsoleteFiles.inc
@@ -6911,6 +6911,11 @@ OLD_FILES+=usr/share/snmp/defs/pf_tree.def
OLD_FILES+=usr/share/snmp/mibs/BEGEMOT-PF-MIB.txt
.endif
+.if ${MK_PKGSERVE} == no
+OLD_FILES+=usr/libexec/pkg-serve
+OLD_FILES+=usr/share/man/man8/pkg-serve.8.gz
+.endif
+
.if ${MK_PKGBOOTSTRAP} == no
OLD_FILES+=usr/sbin/pkg
OLD_FILES+=usr/share/man/man7/pkg.7.gz
diff --git a/tools/build/options/WITHOUT_PKGSERVE b/tools/build/options/WITHOUT_PKGSERVE
new file mode 100644
index 000000000000..ecd3602fc03d
--- /dev/null
+++ b/tools/build/options/WITHOUT_PKGSERVE
@@ -0,0 +1,2 @@
+Do not build or install
+.Xr pkg-serve 8 .