Last time I needed to handle a C++ library project with CMake. This time I was tasked with creating a deb package for one of the libraries in our SDK.

CMake, CPack, deb package

And what would you know, CMake can handle packaging too - with CPack utility.

About CPack

CPack, like CMake itself, is a CLI program. While CMake handles building the project, CPack is responsible for making packages.

The package is simply a “distribution unit” - something that you share with your users, or, in other words, what form your library/SDK/application is delivered in. Quite often it’s just a ZIP archive, but it can be also something more sophisticated, such as a package in one of the package management system formats, such as NuGet, RPM, deb and so on.

CPack comes especially handy if you need to support more than one package format. Instead of dealing with every single package format structure and requirements “manually”, you can let CPack handle all of them, so you’ll only need to worry about getting the actual package contents together.

In my case I needed to make a deb package for one of the libraries of our SDK, so deb package format will be the main focus of this article. Additional complexity of the task was that I needed to make a package only for that library and not for the entire SDK.

An example project

Let’s take the same project that was used as an example in the C++ library article, but make it slightly more complex: now it will have one more library (just to increase the number of components in the project, the main application doesn’t even link to it).

Here’s the project structure:

├── cmake
   ├── InstallingConfigs.cmake
   ├── InstallingGeneral.cmake
   └── Packing.cmake
├── libraries
   ├── AnotherLibrary
      ├── include
         └── another.h
      ├── src
         ├── another.cpp
         └── things.h
      ├── CMakeLists.txt
      └── Config.cmake.in
   ├── SomeLibrary
      ├── include
         └── some.h
      ├── src
         ├── some.cpp
         └── things.h
      ├── CMakeLists.txt
      └── Config.cmake.in
   └── CMakeLists.txt
├── CMakeLists.txt
├── LICENSE
├── README.md
└── main.cpp

For the reference, full project source code is available here.

What is needed to enable packing

Most of the work is done by install() statements. So if you already have installation instructions in your project, then there is not much left for you to do.

As a bare minimum, you need to set some variables for CPack, such as package name and the way you’d like components to be (or not to be) grouped. Then you just include a standard module named CPack into your project.

I put all that into a separate CMake module in the project (/cmake/Packing.cmake):

# these are cache variables, so they could be overwritten with -D,
set(CPACK_PACKAGE_NAME ${PROJECT_NAME}
    CACHE STRING "The resulting package name"
)
# which is useful in case of packing only selected components instead of the whole thing
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Simple C++ application"
    CACHE STRING "Package description for the package metadata"
)
set(CPACK_PACKAGE_VENDOR "Some Company")

set(CPACK_VERBATIM_VARIABLES YES)

set(CPACK_PACKAGE_INSTALL_DIRECTORY ${CPACK_PACKAGE_NAME})
SET(CPACK_OUTPUT_FILE_PREFIX "${CMAKE_SOURCE_DIR}/_packages")

# https://unix.stackexchange.com/a/11552/254512
set(CPACK_PACKAGING_INSTALL_PREFIX "/opt/some")#/${CMAKE_PROJECT_VERSION}")

set(CPACK_PACKAGE_VERSION_MAJOR ${PROJECT_VERSION_MAJOR})
set(CPACK_PACKAGE_VERSION_MINOR ${PROJECT_VERSION_MINOR})
set(CPACK_PACKAGE_VERSION_PATCH ${PROJECT_VERSION_PATCH})

set(CPACK_PACKAGE_CONTACT "YOUR@E-MAIL.net")
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "YOUR NAME")

set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_CURRENT_SOURCE_DIR}/LICENSE")
set(CPACK_RESOURCE_FILE_README "${CMAKE_CURRENT_SOURCE_DIR}/README.md")

# package name for deb
# if set, then instead of some-application-0.9.2-Linux.deb
# you'll get some-application_0.9.2_amd64.deb (note the underscores too)
set(CPACK_DEBIAN_FILE_NAME DEB-DEFAULT)
# if you want every group to have its own package,
# although the same happens if this is not sent (so it defaults to ONE_PER_GROUP)
# and CPACK_DEB_COMPONENT_INSTALL is set to YES
set(CPACK_COMPONENTS_GROUPING ALL_COMPONENTS_IN_ONE)#ONE_PER_GROUP)
# without this you won't be able to pack only specified component
set(CPACK_DEB_COMPONENT_INSTALL YES)

include(CPack)

According to this, the package name (CPACK_PACKAGE_NAME) should have at least one dash/hyphen, so you might want/need to set it like this:

# ${namespace} could be your main project name, or company, or whatever
set(CPACK_PACKAGE_NAME "${namespace}-${PROJECT_NAME}"
    CACHE STRING "The resulting package name"
)

And of course the same applies if you pass it as -DCPACK_PACKAGE_NAME on configuration.

Once you have the Packing.cmake module, include it in the very end of the main project (/CMakeLists.txt):

# where to find our CMake modules
set(CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
include(Packing)

To create a package you need to configure the project, build it and then execute cpack:

$ cd /path/to/cmake-cpack-example
$ mkdir build && cd $_
$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release ..
$ cmake --build .

$ cpack -G DEB
CPack: Create package using DEB
CPack: Install projects
CPack: - Install project: some-application []
CPack: -   Install component: AnotherLibrary
CPack: -   Install component: SomeLibrary
CPack: -   Install component: some-application
CPack: Create package
-- CPACK_DEBIAN_PACKAGE_DEPENDS not set, the package will have no dependencies.
CPack: - package: /path/to/cmake-cpack-example/_packages/some-application_0.9.2_amd64.deb generated.

As you can guess, -G DEB means that CPack will create a deb package. The full list of supported package formats (or rather CPack generators) is available here.

Note that even though packing depends on install() statements, actual installation step (--target install) wasn’t required here.

But building the target is required. If you try to call cpack without building anything, it will fail:

$ cpack -G DEB
CPack: Create package using DEB
CPack: Install projects
CPack: - Install project: some-application []
CPack: -   Install component: SomeLibrary
CMake Error at /path/to/cmake-cpack-example/build/libraries/SomeLibrary/cmake_install.cmake:41 (file):
  file INSTALL cannot find
  "/path/to/cmake-cpack-example/build/libraries/SomeLibrary/libSomeLibrary.a":
  No such file or directory.
Call Stack (most recent call first):
  /path/to/cmake-cpack-example/build/libraries/cmake_install.cmake:42 (include)
  /path/to/cmake-cpack-example/build/cmake_install.cmake:42 (include)

CPack Error: Error when generating package: some-application

If everything required for the package was built, and packing went fine, then you’ll get a package in the project root:

_packages
└── some-application_0.9.2_amd64.deb

Let’s take a look at what’s inside:

$ cd ../_packages/
$ dpkg-deb -R ./some-application_0.9.2_amd64.deb ./package
$ tree ./package/
./package/
├── DEBIAN
   ├── control
   └── md5sums
└── opt
    └── some
        ├── bin
           └── some-application
        ├── cmake
           ├── AnotherLibraryConfig.cmake
           ├── AnotherLibraryConfigVersion.cmake
           ├── AnotherLibraryTargets-release.cmake
           ├── AnotherLibraryTargets.cmake
           ├── SomeLibraryConfig.cmake
           ├── SomeLibraryConfigVersion.cmake
           ├── SomeLibraryTargets-release.cmake
           └── SomeLibraryTargets.cmake
        ├── include
           ├── AnotherLibrary
              └── another.h
           └── SomeLibrary
               └── some.h
        └── lib
            ├── libAnotherLibrary.a
            └── libSomeLibrary.a

As you can see, it’s the same content as what we would get in the install folder after running --install/--target install, but prefixed with the specified installation path (/opt/some) and with the package meta-information and checksums in DEBIAN folder on top.

Components

Well, that was easy. But what wasn’t easy is to find out how to tell CPack to pack only selected components instead of the entire project.

For selective packing you need to define so-called components in the install() statements. If an install() statement doesn’t have a COMPONENT set, then it will default to Unspecified component. So by default the entire project is an Unspecified component, and that is what will get packed.

Here’s how you can set COMPONENT in an install() statement:

install(
    TARGETS ${PROJECT_NAME}
    EXPORT "${PROJECT_NAME}Targets"
    COMPONENT ${PROJECT_NAME} # must be here, not any line lower
    # these get default values from GNUInstallDirs, no need to set them
    #RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} # bin
    #LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} # lib
    #ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} # lib
    # except for public headers, as we want them to be inside a library folder
    PUBLIC_HEADER DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/${PROJECT_NAME}
    INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} # include
)

# ...

install(
    FILES
    "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}Config.cmake"
    "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}ConfigVersion.cmake"
    DESTINATION cmake
    COMPONENT ${PROJECT_NAME}
)

It is important that in case of install(TARGETS ...) the COMPONENT value should go right after EXPORT, otherwise it kind of doesn’t apply or applies to something else?.. I don’t quite understand, what is going on here, but that’s how it worked for me.

To check what components will get packed, you can print the CPACK_COMPONENTS_ALL variable:

message(STATUS "Components to pack: ${CPACK_COMPONENTS_ALL}")

So here I’ve set COMPONENT to every single install() statement in the project, and here’s what I get on configuring the project:

-- Components to pack: AnotherLibrary;SomeLibrary;some-application

Out of curiosity, let’s comment out COMPONENT for the application’s install() and run configure again:

-- Components to pack: AnotherLibrary;SomeLibrary;Unspecified

So if you get Unspecified in the CPACK_COMPONENTS_ALL variable, then most definitely you have at least one install() statement somewhere in the project without COMPONENT set.

The very same CPACK_COMPONENTS_ALL variable is how you control which components need to be packed. If I want to pack only SomeLibrary and some-application components, then I need to pass -DCPACK_COMPONENTS_ALL="SomeLibrary;some-application" on configure (you might need to update your CMake for multi-target --build instruction to work):

$ cd /path/to/cmake-cpack-example/build

$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCPACK_COMPONENTS_ALL="SomeLibrary;some-application" \
..

$ cmake --build . --target "SomeLibrary;some-application"

$ cpack -G DEB
CPack: Create package using DEB
CPack: Install projects
CPack: - Install project: some-application []
CPack: -   Install component: SomeLibrary
CPack: -   Install component: some-application
CPack: Create package
-- CPACK_DEBIAN_PACKAGE_DEPENDS not set, the package will have no dependencies.
CPack: - package: /path/to/cmake-cpack-example/_packages/some-application_0.9.2_amd64.deb generated.

$ dpkg-deb -R ../_packages/some-application_0.9.2_amd64.deb ../_packages/package
$ tree ../_packages/package/
../_packages/package/
├── DEBIAN
   ├── control
   └── md5sums
└── opt
    └── some
        ├── bin
           └── some-application
        ├── cmake
           ├── SomeLibraryConfig.cmake
           ├── SomeLibraryConfigVersion.cmake
           ├── SomeLibraryTargets-release.cmake
           └── SomeLibraryTargets.cmake
        ├── include
           └── SomeLibrary
               └── some.h
        └── lib
            └── libSomeLibrary.a

Here we go, AnotherLibrary wasn’t packed this time.

Sadly, CPACK_COMPONENTS_ALL (and other useful CPack variables) can be set only on project configuration, not on cpack run.

Preinstall target

As at first I ran CMake with default generator, it was using Unix Makefiles (which is the default one on some systems, such as Mac OS). And for a long time I couldn’t understand why, even though I have set specific components to pack and built required targets, I was getting a Run preinstall target step, which was building the entire project before packing the components I asked.

Here’s how this can be reproduced:

$ cmake -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release \
-DCPACK_COMPONENTS_ALL="SomeLibrary;some-application" \
..

$ cmake --build . --target "SomeLibrary;some-application"

$ cpack -G DEB
CPack: Create package using DEB
CPack: Install projects
CPack: - Run preinstall target for: some-application
CPack: - Install project: some-application []
CPack: -   Install component: SomeLibrary
CPack: -   Install component: some-application
CPack: Create package
-- CPACK_DEBIAN_PACKAGE_DEPENDS not set, the package will have no dependencies.
CPack: - package: /path/to/cmake-cpack-example/_packages/some-application_0.9.2_amd64.deb generated.

In fact, even if you won’t explicitly build required targets (or anything at all), cpack will be still fine, because this “preinstall target” will anyway build the entire project.

It will pack only what was passed in CPACK_COMPONENTS_ALL, no problem here, but it will always build the entire project. That’s absolutely not good news, because our SDK takes quite some time to build, and either way it’s just a waste of resources to build everything, when you only need a (small) part of the project.

I don’t know if such behavior is by design or is it a bug, but solution/workaround is simple: do not use Unix Makefiles generator. Instead, use Ninja, Visual Studio *, Xcode - none of these invoke that “preinstall target”. Alternatively, you can still use Unix Makefiles for configuring and building the project, but pass -DCPACK_CMAKE_GENERATOR=Ninja to cpack.

Keeping Lintian happy

The deb package produced by CPack above is already fine and can be installed with dpkg. However, there is a tool for running checks on packages - Lintian - and it will not be happy about that package quality:

$ lintian /path/to/cmake-cpack-example/_packages/some-application_0.9.2_amd64.deb
E: some-application: changelog-file-missing-in-native-package
E: some-application: dir-or-file-in-opt opt/some/
E: some-application: dir-or-file-in-opt opt/some/bin/
E: some-application: dir-or-file-in-opt ... use --no-tag-display-limit to see all (or pipe to a file/program)
E: some-application: extended-description-is-empty
E: some-application: maintainer-address-missing YOUR NAME
E: some-application: no-copyright-file
E: some-application: unstripped-binary-or-object opt/some/bin/some-application
W: some-application: missing-depends-line
W: some-application: non-standard-dir-perm opt/ 0775 != 0755
W: some-application: non-standard-dir-perm opt/some/ 0775 != 0755
W: some-application: non-standard-dir-perm ... use --no-tag-display-limit to see all (or pipe to a file/program)

Some things we can’t / don’t want to do anything about, as they are meant for proper system package maintainers (which we are not), such as:

  • changelog-file-missing-in-native-package - we don’t really have a changelog in a form that is ready to be packed;
  • dir-or-file-in-opt - only natural, as this is intentional;
  • extended-description-is-empty - yeah, we don’t have a description for every single library;
  • no-copyright-file - indeed, there isn’t one.

But some warnings can be easily fixed with the right CPack variables in /cmake/Packing.cmake.

maintainer-address-missing

The maintainer name should contain not just his name but also his e-mail address. And you need to set CPACK_DEBIAN_PACKAGE_MAINTAINER exactly so:

set(CPACK_PACKAGE_CONTACT "YOUR@E-MAIL.net")
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "YOUR NAME <${CPACK_PACKAGE_CONTACT}>")

unstripped-binary-or-object

The some-application executable needs to be stripped from debug symbols. I am a bit confused about the fact that it still has them despite the Release build configuration, but okay, you can explicitly strip binaries with this CPack variable:

set(CPACK_STRIP_FILES YES)

missing-depends-line

A good package should list its dependencies. This can be turned on with the following variable:

set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS YES)

As a result, in the case of my silly project, the following line will get added to /some-application_0.9.2_amd64.deb/DEBIAN/control:

Depends: libc6 (>= 2.2.5), libstdc++6 (>= 5.2)

For that to work you need to have dpkg-shlibdeps utility installed.

non-standard-dir-perm

The installation path directory should have 0755 permissions:

set(
    CPACK_INSTALL_DEFAULT_DIRECTORY_PERMISSIONS
    OWNER_READ OWNER_WRITE OWNER_EXECUTE
    GROUP_READ GROUP_EXECUTE
    WORLD_READ WORLD_EXECUTE
)

Hosting deb packages for APT

This is a bonus part: how to set-up an APT repository with restricted access to host your deb packages. Mostly based on this beautiful blog post.

Repository

The deb packages repository is comprised by:

  • a certain folders structure (with actual packages inside);
  • several special text files, describing the repository contents, namely Packages and Release (and their compressed/signed variations).

That’s really what is there to it. The rest is just the way you make the repository available to users, what transport is used to fetch packages from it.

Let’s say we configured and packed only SomeLibrary component:

$ cmake -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCPACK_COMPONENTS_ALL="SomeLibrary" \
-DCPACK_PACKAGE_NAME="some-library" \
-DCPACK_PACKAGE_DESCRIPTION_SUMMARY="Some library that prints a line of text" \
..

$ cmake --build . --target SomeLibrary

$ cpack -G DEB
CPack: Create package using DEB
CPack: Install projects
CPack: - Install project: some-application []
CPack: -   Install component: SomeLibrary
CPack: Create package
-- CPACK_DEBIAN_PACKAGE_DEPENDS not set, the package will have no dependencies.
CPack: - package: /path/to/cmake-cpack-example/_packages/some-library_0.9.2_amd64.deb generated.

And now we want to put it into an APT repository, so people could fetch and install it on their machines with apt install, like any other package.

We’ll be of course using a Linux server for hosting and serving the repository, so let’s start with creating the following structure on that server:

/path/to/somewhere/
└── repository
    ├── dists
     └── stable
         └── main
             └── binary-amd64
    └── pool
        └── main
            └── some-library_0.9.2_amd64.deb

So the deb packages go to pool/main, and we uploaded our first some-library_0.9.2_amd64.deb there.

Now we need to create a list of available packages - the Packages file plus its compressed variant - it is generated by dpkg-scanpackages utility:

$ cd /path/to/somewhere/repository
$ dpkg-scanpackages -m --arch amd64 pool/ > ./dists/stable/main/binary-amd64/Packages
$ cat ./dists/stable/main/binary-amd64/Packages | gzip -9 > ./dists/stable/main/binary-amd64/Packages.gz

The -m option allows to have several version of the package (should be the default assumption, I think, otherwise how does one have several versions of the package in the repository?). Without it, when you’ll have the next version of your package (some-library_0.9.3_amd64.deb) and will scan the that folder again, you’ll get a warning, and only one package will be written to Packages file:

dpkg-scanpackages: warning: package some-library (filename ./pool/main/some-library_0.9.2_amd64.deb) is repeat; ignored that one and using data from ./pool/main/some-library_0.9.3_amd64.deb!
dpkg-scanpackages: info: Wrote 1 entries to output Packages file.

And yes, the Packages file and its compressed variant need to be updated every time you upload a new package.

Next thing we need to do is to prepare a Release file. Just like Packages, it also needs to be updated on every new uploaded package. And since its generation is a bit more complex, it’s a good idea to make a script for this task:

#!/bin/sh

set -e

do_hash() {
    HASH_NAME=$1
    HASH_CMD=$2
    echo "${HASH_NAME}:"
    for f in $(find -type f); do
        f=$(echo $f | cut -c3-) # remove ./ prefix
        if [ "$f" = "Release" ]; then
            continue
        fi
        echo " $(${HASH_CMD} ${f}  | cut -d" " -f1) $(wc -c $f)"
    done
}

cat << EOF
Origin: Some repository
Label: Some
Suite: stable
Codename: stable
Version: 1.0
Architectures: amd64
Components: main
Description: Some software repository
Date: $(date -Ru)
EOF
do_hash "MD5Sum" "md5sum"
do_hash "SHA1" "sha1sum"
do_hash "SHA256" "sha256sum"

What the script does is it describes the repository and also provides checksums for the Packages files. Save it as generate-release.sh and execute to make the Release file:

$ cd /path/to/somewhere/repository/dists/stable/
$ /path/to/elsewhere/generate-release.sh > ./Release

We should also sign the Release file with PGP, and that will be covered a bit later, but the repository can already serve packages to APT as it is - you only need to set-up a transport for it.

Transport

APT supports several transports. SSH and HTTP/HTTPS I was aware of and will demonstrate them below, but apparently it can also use AWS S3 and Tor, which was news to me.

SSH

At first I thought that I can just expose the repository via SFTP using a chrooted user, and so that’s what I did on the repository server. My reasoning for going with SFTP was to use SSH keys (instead of Basic authentication in case of HTTP/HTTPS).

Start with adding a “guest” user:

$ sudo addgroup packages
$ sudo adduser --ingroup packages --home /home/packages packages

and move the repository to /home/packages/:

$ mv /path/to/somewhere/repository /home/packages/
$ sudo chown -R packages:packages /home/packages

Allow chrooted SFTP access for that user in /etc/ssh/sshd_config:

...

Subsystem sftp internal-sftp

...

Match Group packages
    # force the connection to use SFTP and chroot to the required directory
    ForceCommand internal-sftp
    ChrootDirectory /home/packages
    # disable tunneling
    PermitTunnel no
    # disable authentication agent
    AllowAgentForwarding no
    # disable TCP forwarding
    AllowTcpForwarding no
    # disable X11 forwarding
    X11Forwarding no
    # disable password, only key is allowed
    PasswordAuthentication no

and restart SSH service:

$ sudo systemctl restart sshd.service

Generate SSH key, add its public part to /home/packages/.ssh/authorized_keys on server. Create an entry in ~/.ssh/config on client/user machine with private key (might need to do that for root user too, because apt is likely to be called with sudo).

Add a new APT source on the client/user machine in /etc/apt/sources.list.d/some.list:

deb [arch=amd64 trusted=yes] ssh://your.host/repository stable main

The trusted=yes parameter is required, since our repository is not signed yet.

Now try to fetch the packages information:

$ sudo apt update

That might fail:

The method 'ssh' is unsupported and disabled by default. Consider switching to http(s). Set Dir::Bin::Methods::ssh to "ssh" to enable it again

If you got that error, create /etc/apt/apt.conf.d/99ssh file with the following content:

Dir::Bin::Methods::ssh "ssh";

and try again:

$ sudo apt update
...
Reading package lists... Done
E: Method ssh has died unexpectedly!
E: Sub-process ssh received signal 13.

This error happens because APT cannot work via SFTP only, as it actually needs proper SSH to run certain commands on the repository host. I find it pretty amusing, considering that APT works via HTTP/HTTPS just fine without the necessity to run remote commands.

But okay, our options then are to either make a “jailed chroot” (a very limited and restricted system environment) for the packages user or to give it proper SSH access. I didn’t like either of these options, especially the last one, but just to finish this part, let’s consider the latter, as it’s the easiest.

Go to /etc/ssh/sshd_config again and comment out these lines:

#ForceCommand internal-sftp
#ChrootDirectory /home/packages

Restart SSH service and now you should be able to fetch and install the packages:

$ sudo apt update
$ sudo apt install some-library
$ ls -l /opt/some

HTTPS

That seems to be a more common way of connecting to APT repositories. Too bad SSH option turned out to be not what I expected.

Of course, for HTTP/HTTPS you’ll need a web-server, and I’ll be using NGINX, but any other will be fine too (as long as it supports Basic authentication).

Move the repository to website root:

$ mv /path/to/somewhere/repository /var/www/your.host/files/packages/deb
$ sudo chown -R www-data:www-data /var/www/your.host/files/packages

Create a password for Basic authentication:

$ sudo htpasswd -c /etc/nginx/packages.htpasswd packages

Edit NGINX site config (/etc/nginx/sites-enabled/your.host):

location /files/packages/deb/ {
    root /var/www/your.host;
    auth_basic           "restricted area";
    auth_basic_user_file /etc/nginx/packages.htpasswd;
    #autoindex on;
    try_files $uri $uri/ =404;
}

Add the new source on client/user machine to /etc/apt/sources.list.d/some.list:

deb [arch=amd64 trusted=yes] https://your.host/files/packages/deb stable main

The trusted=yes parameter is required, since our repository is not signed yet.

Also add credentials (Basic authentication) for that host into /etc/apt/auth.conf.d/some:

machine your.host
login packages
password HERE-GOES-PASSWORD

Now you should be able to fetch and install packages:

$ sudo apt update
$ sudo apt install some-library
$ ls -l /opt/some

Secure APT

It is recommended not to use trusted=yes parameter in APT source setting and instead to sign the repository with PGP key.

For that you’ll need to create a PGP key, if you don’t already have one:

$ gpg --full-generate-key

If you are doing this on the server via SSH, you’ll wait forever for the key generation to finish, because it expects some input from background activities to gain entropy. Connect to the same server from another tab and run something like:

$ dd if=/dev/sda of=/dev/zero

or maybe:

$ find / | xargs file

Alternatively, you can just generate the key on your local machine and import it on the server.

Once you have the key, export its public part:

$ gpg --list-secret-keys --keyid-format=long
/root/.gnupg/pubring.kbx
------------------------
sec   rsa4096/KEY-ID 2021-09-18 [SCE]
      SOME-OTHER-ID
uid                 [ultimate] Some Software <admin@your.host>

$ gpg --armor --export KEY-ID > /path/to/somewhere/repository/some-public.asc
$ cat /path/to/somewhere/repository/some-public.asc | gpg --list-packets

Just in case, check that there is only one :public key packet: in the output, otherwise APT might get confused by such a key.

You can now sign the Release file:

$ cd /path/to/somewhere/repository/dists/stable
$ export GPG_TTY=$(tty)
$ cat ./Release | gpg --default-key KEY-ID -abs > ./Release.gpg
$ cat ./Release | gpg --default-key KEY-ID -abs --clearsign > ./InRelease

If you protected the key with a password (you should have), then you’ll get the key password prompt. And if you’ll need to execute these commands “unattended” from a buildbot user in some CI/CD, then you’ll need to add --pinentry-mode=loopback and --passphrase options:

$ cat ./Release | gpg --default-key KEY-ID --pinentry-mode=loopback --passphrase "PGP-KEY-PASSWORD" -abs > ./Release.gpg
$ cat ./Release | gpg --default-key KEY-ID --pinentry-mode=loopback --passphrase "PGP-KEY-PASSWORD" -abs --clear-sign > ./InRelease

Since we are talking about buildbots, you’ll also need to make sure that they have access to the keychain. You might need to either move .gnupg folder to buildbot’s home folder or set the GNUPGHOME environment variable. In both cases don’t forget to change the ownership of that folder or set required permissions.

Now you can remove trusted=yes from /etc/apt/sources.list.d/some.list:

deb [arch=amd64] https://your.host/files/packages/deb stable main

If then you try to run apt update, you’ll get an error about unknown key:

W: GPG error: https://your.host/files/packages/deb stable InRelease: The following signatures couldn't be verified because the public key is not available: NO_PUBKEY KEY-ID
W: Failed to fetch https://your.host/files/packages/deb/dists/stable/InRelease  The following signatures couldn't be verified because the public key is not available: NO_PUBKEY KEY-ID

Put the public key (some-public.asc) somewhere public on the server and import it on client:

$ wget -O - https://your.host/some-public.asc 2>/dev/null | gpg --dearmor - | sudo tee /usr/share/keyrings/some-keyring.gpg >/dev/null

Add signed-by parameter to /etc/apt/sources.list.d/some.list:

deb [arch=amd64 signed-by=/usr/share/keyrings/some-keyring.gpg] https://your.host/files/packages/deb stable main

Finally, it’s all good:

$ sudo apt update
$ sudo apt install some-library
$ tree /opt/some
/opt/some
├── cmake
   ├── SomeLibraryConfig.cmake
   ├── SomeLibraryConfigVersion.cmake
   ├── SomeLibraryTargets-release.cmake
   └── SomeLibraryTargets.cmake
├── include
   └── SomeLibrary
       └── some.h
└── lib
    └── libSomeLibrary.a