pkg-serve(8): serve pkg repositories over TCP via inetd (8)

Reviewed by:	manu, bdrewery (previous version)
Differential Revision:	https://reviews.freebsd.org/D55895
This commit is contained in:
Baptiste Daroussin
2026-03-17 12:02:28 +01:00
parent a0170dbd4e
commit b42e852e89
10 changed files with 545 additions and 0 deletions
+2
View File
@@ -477,6 +477,8 @@
..
nuageinit
..
pkg-serve
..
rc
..
rtld-elf
+4
View File
@@ -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
+9
View File
@@ -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>
+107
View File
@@ -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
+180
View File
@@ -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);
}
+5
View File
@@ -0,0 +1,5 @@
PACKAGE= tests
ATF_TESTS_SH= pkg_serve_test
.include <bsd.test.mk>
+230
View File
@@ -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
}
+1
View File
@@ -156,6 +156,7 @@ __DEFAULT_YES_OPTIONS = \
PAM \
PF \
PKGBOOTSTRAP \
PKGSERVE \
PMC \
PPP \
PTHREADS_ASSERTIONS \
+5
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
Do not build or install
.Xr pkg-serve 8 .