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:
@@ -477,6 +477,8 @@
|
||||
..
|
||||
nuageinit
|
||||
..
|
||||
pkg-serve
|
||||
..
|
||||
rc
|
||||
..
|
||||
rtld-elf
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
PACKAGE= tests
|
||||
|
||||
ATF_TESTS_SH= pkg_serve_test
|
||||
|
||||
.include <bsd.test.mk>
|
||||
@@ -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
|
||||
}
|
||||
@@ -156,6 +156,7 @@ __DEFAULT_YES_OPTIONS = \
|
||||
PAM \
|
||||
PF \
|
||||
PKGBOOTSTRAP \
|
||||
PKGSERVE \
|
||||
PMC \
|
||||
PPP \
|
||||
PTHREADS_ASSERTIONS \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
Do not build or install
|
||||
.Xr pkg-serve 8 .
|
||||
Reference in New Issue
Block a user