Page Menu
Home
FreeBSD
Search
Configure Global Search
Log In
Files
F149451260
D55895.id173800.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Flag For Later
Award Token
Size
12 KB
Referenced Files
None
Subscribers
None
D55895.id173800.diff
View Options
diff --git a/etc/mtree/BSD.tests.dist b/etc/mtree/BSD.tests.dist
--- 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
--- a/libexec/Makefile
+++ b/libexec/Makefile
@@ -14,6 +14,7 @@
${_makewhatis.local} \
${_mknetid} \
${_phttpget} \
+ ${_pkgserve} \
${_pppoed} \
rc \
revnetgroup \
@@ -65,6 +66,10 @@
_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
--- /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
--- /dev/null
+++ b/libexec/pkg-serve/pkg-serve.8
@@ -0,0 +1,107 @@
+.\" Copyright (c) 6 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
--- /dev/null
+++ b/libexec/pkg-serve/pkg-serve.c
@@ -0,0 +1,179 @@
+/*-
+ * 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;
+ ssize_t linelen;
+ 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;
+ }
+
+ /* path traversal protection */
+ if (strstr(file, "..") != NULL) {
+ printf("ko: file not found\n");
+ fflush(stdout);
+ continue;
+ }
+
+ if (fstatat(fd, file, &st, 0) == -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)) == -1) {
+ printf("ko: file not found\n");
+ fflush(stdout);
+ continue;
+ }
+
+ printf("ok: %" PRIdMAX "\n", (intmax_t)st.st_size);
+ fflush(stdout);
+
+ while ((r = read(ffd, buf, sizeof(buf))) > 0) {
+ if (fwrite(buf, 1, r, stdout) != r)
+ break;
+ }
+ fflush(stdout);
+
+ close(ffd);
+ }
+
+ free(line);
+ close(fd);
+
+ return (EXIT_SUCCESS);
+}
diff --git a/libexec/pkg-serve/tests/Makefile b/libexec/pkg-serve/tests/Makefile
new file mode 100644
--- /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
--- /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/tools/build/options/WITHOUT_PKGSERVE b/tools/build/options/WITHOUT_PKGSERVE
new file mode 100644
--- /dev/null
+++ b/tools/build/options/WITHOUT_PKGSERVE
@@ -0,0 +1,2 @@
+Do not build or install
+.Xr pkg-serve 8 .
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Wed, Mar 25, 1:36 PM (17 h, 3 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
30326034
Default Alt Text
D55895.id173800.diff (12 KB)
Attached To
Mode
D55895: pkg-serve(8): serve pkg repositories over TCP via inetd
Attached
Detach File
Event Timeline
Log In to Comment