The aim of this post is showing a tidy way to structure a C o C++ project managing the build lifecycle using the GNU Make and packaging it as RPM.
The post demonstrates a full featured C project managed by make and packaged as RPM, showing how to set up a tidy structure, develop and package a C application with its own shared objects, that reads the configuration from a file, validates settings, logs events into a file and handles error conditions printing to standard error and setting properly shell return code.
This post is certainly useful not only to developers, but to anybody who wants to learn how to build third part C or C++ software, since it clearly describes the compilation and linking process. In addition to that, we also learn how to create the product certificate that can be exploited by the subscription-manager to know that the product is installed on the system.
The application is then packaged, besides as a gzipped tarball, also as RPM, creating the application package, the package with the development resource files (the C include files) and the package with the debug information that can be used with a debugger to troubleshoot things.
When I decided to write this post I was a little bit nostalgic, ... I developed a lot using the C programming language (I mostly used C++, honestly) when I was a little bit more than a teenager, ... this reminds me of the last years at the high school and the first years at university.
I was really impressed by what Dennis Ritchie and Bjarne Stroustrup did. Even nowadays we find a lot of emblazoned applications that have been developed using C and C++ - despite nowadays I firmly believe that Golang is the new way forward.
I chose to show how to set up a project using the programming language created by Dennis Ritchie, to celebrate a programming language that after 50 years is still here, so I think that it is kind to claim:
In memory of Dennis Ritchie, father of the C programming language and key developer of the UNIX.
Improving The Software Maintainability And The Easiness Of Delivery And Installation
There are several things to consider when planning the structure of a program - I won't tell you about the best practices about the code itself - this post is not about the best practices for your C code: instead this post focuses on highlighting the best practices, broadly used since many years, that helps to ease the software maintainability and delivery, making it more friendly to install, maintain and and support.
Provide The Software Development Files
Unless you are working on a trivial application, it is quite likely that some advanced users may want to write integrations to it, and so you must find a convenient way to deliver the Software Development Kit (SDK) with all the resource files necessary to develop integrations with the application.
Note that when dealing with C or C++ applications, this does not mean that you must provide the full source code of the application: providing headers files and shared libraries is enough to let other people be able to write their own code that easily links to yours.
Anyway mind that delivering the full sources of the application is always interpreted as a signal of transparency.
Provide The Help Command Switch And Man Pages
Your application certainly has command line utilities with options, ... of course an help command switch is handy (such has -h or --help), but a man page provides much more space to better explain concepts, and on UNIX and Linux we are all used to lookup man pages of command line tools.
So do not forget to provide man pages: they are the most handy way to get quick help.
Provide Documentation
Of course you are then expected to integrate them with more modern documentation that handles also graphical and or multimedia contents, such as HTML or PDF, ... your users are certainly glad to have documentation files provided along with the application.
Be aware Of Your Target Platforms
Professionally providing an application is much more than simply creating a package and the deliverying it: the application and the installation package must be designed so as to integrate at its best with the target platform.
For example, Linux has a standard filesystem layout: a well designed application must be compatible with the standard Linux filesystem layout and exploit it, allowing to place configuration and log files in the most convenient path.
But at the same time you must provide a way to install things within a directory tree beneath a common root, so that unprivileged users (such as the developers themselves) can install it for example for testing.
Exploit The Package Manager Specific Of The Platform
Package the application using a format that is supported by the native Package Manager of the platform - this provides several benefits, such as:
- easily install the application
- quickly identify installed files and version
- easily upgrade the application
- easily uninstall the application
The Development Environment
A development environment for C (or C++) requires just a few tools:
- a C (or C++) compiler
- the C libraries for the architecture in use
- a project handling utility that helps us to work when dealing with hundreds, if not thousands of files.
Install The C Compiler And The Make Utility
So the utility we need to install are:
- gcc - the C compiler and linker
- make - the management system of the project
Let's install the development tools as follows:
sudo dnf install -y gcc make
Install RPM Build Tools
We also need the RPM development tools for packaging the application files and sources: for example RPMs are built using the rpmbuild command line utility indeed.
We can install them along with other handy tools such as rpmdevtools and rpmlint as follows:
sudo dnf install -y rpm-build rpmdevtools rpmlint
Working as A Regular User
In this post we work as the "student" user, so login as the user you want to use for developing or switch to it using sudo:
sudo su - student
A good way of working is having a directory to store every developed project into: in this post this is the "devel" directory: create it as follows:
mkdir -m 755 ~/devel
Creating the project folder tree
Let's change to the "devel" directory and create the root directory of the project for the "foo" application we want to develop and package:
cd ~/devel
mkdir -m 755 foo
and of course change to this directory:
cd ~/devel/foo
we are now in the root directory of the project of the "foo" application: here we must create the directory tree with a layout that keeps things tidy and easy to find, so to ease the maintenance of the project.
Let's begin by creating the directories where we organize files:
mkdir -m 755 src etc bin lib log man doc cert RPM
the purpose of each directory is:
- src - the source files of the program
- bin - store the binary files generated by the build
- lib - store the shared object files generated by the build
- etc - store configuration files
- log - store log files written by the application while running
- man - store man files
- doc - store documentation files, such as README files. HTML, MD files, PDF and so on
- cert - store the product certificate
- RPM - store the RPM spec file used to build the RPM packages that contain the application and its sources
Developing the application
Let's begin developing the program - change to the "src" directory:
cd src
programs developed using the C programming language are made of two kind of files:
- header files, that contain only the headers of the functions, without implement anything
- C code files, that contain the code necessary to implement the functions declared in the headers
The outcome of the building of these files are the object files. Programs are then assembled by the linker by linking together all the object files, creating a monolithic executable file.
An alternate more handy way is using shared object files (*.so): when exploiting this feature the linker assembles the executable, but delays the actual loading of the code contained in the object file until the time of the actual run, when it actually loads them from the linked shared libraries (the .so files).
Header files are used by the compiler to check the semantic of the call to functions, so they must be detached from the main RPM package, and put into a "devel" package.
When dealing with large C projects, there are often hundreds and even thousands of headers and C code files: to enforce tidiness, files are often logically grouped by purpose into directories.
By the way it seems that I forgot to mention the purpose of this program, ... that's very Agile ;O) . So don't worry about the features, we must welcome them later on.
Anyway, any good program must at least:
- load its configuration from a file
- log its status messages into a file
We can group these two features in a common feature called "facilities".
Implementing the facilities feature
Let's create the "facilities" directory within the "src" directory:
mkdir -m 755 facilities
change to the "facilities" directory:
cd facilities
At first let's create the "logging.h" header file where we declare functions that implements the logging handling feature:
#ifndef LOGGING_H_
#define LOGGING_H_
#define LOGGING_FILE "../log/foo.log"
#include <stdio.h>
FILE *openlog(void);
int logmessage(FILE *fp, char *message);
void closelog(FILE *fp);
#endif
then let's create the "logging.c" file with the code that implements it:
#include "logging.h"
#include <time.h>
FILE *openlog(void) {
FILE *fp;
fp = fopen(LOGGING_FILE, "a");
if (fp == NULL) fprintf( stderr, "Unable to open \"%s\" log file\n", LOGGING_FILE);
return fp;
}
int logmessage(FILE *fp, char *message){
time_t timer;
char timestamp[26];
struct tm* tm_info;
timer = time(NULL);
tm_info = localtime(&timer);
strftime(timestamp, 26, "%Y-%m-%d %H:%M:%S", tm_info);
return fprintf(fp, "%s - %s", timestamp, message);
}
void closelog(FILE *fp){
fclose(fp);
}
same way, lets create the "config.h" header file where we declare functions that implements the configuration handling feature:
#ifndef CONFIG_H_
#define CONFIG_H_
#define CONFIG_FILE "../etc/foo.conf"
#include <stdio.h>
struct settings {
char behavior[255];
};
int loadconfig(struct settings *config, FILE *logfp);
#endif
and eventually we create the "config.c" file with the code that implements it:
#include <stdlib.h>
#include <string.h>
#include "config.h"
#include "logging.h"
int loadconfig(struct settings *config, FILE *logfp) {
FILE * fp;
char * line = NULL;
char *key;
char *value;
size_t len = 0;
ssize_t read;
int status = 0;
fp = fopen(CONFIG_FILE, "r");
if (fp == NULL) {
fprintf( stderr, "Unable to open \"%s\" file\n", CONFIG_FILE);
return -1;
}
while ( (read=getline(&line, &len, fp) ) != -1 ) {
key=strtok(line, "=");
value=strtok(NULL, "\n");
if(strcmp(key, "behavior")==0) {
if (strcmp(value,"kind") !=0 && strcmp(value,"aggressive")!=0) {
fprintf( stderr, "invalid value \"%s\" for setting behavior: can be either \"kind\" or \"aggressive\"\n", value);
logmessage(logfp, "invalid value for setting behavior: can be either \"kind\" or \"aggressive\"\n");
status=-1;
}
strncpy(config->behavior, value, 255);
}
}
fclose(fp);
if (line) free(line);
return status;
}
please note that the code in this file requires also the code of the logging feature - you can guess it from the #include "logging.h" directive.
We must now create the "Makefile" file that governs the build lifecycle of the files in this directory ("src/facilities"):
all: foo
foo:
gcc -Wall -g -c -fPIC config.c
gcc -Wall -g -c -fPIC logging.c
gcc -shared config.o logging.o -o ../../lib/libfacilities.so
clean:
rm -f *.o
install:
install -m 0755 -d $(ROOT)$(INCLUDEDIR)/facilities
install -m 0755 *.h $(ROOT)$(INCLUDEDIR)/facilities
dist: clean
as you see we define the following targets:
- foo: compiles the sources generating object files (-c option) with Position-Independent Code (fPIC option) - this particular kind of code is necessary since it puts them into the libfacilities.so shared object beneath the "lib" directory of the project ("~/devel/foo/lib")
- clean: deletes the built object files - that is cleanup the workspace
- install: install the header files into the desired path
- dist: an empty target that launches the "clean" target. it is unused here, but is required by the master Makefile we will develop more over.
If you are curious to see things in action, we can already have a go as follows:
make
The outcome is the libfacilities.so file into the "lib" directory of the project - as you can easily guess, this is a shared library.
We can of course cleanup the directory from the built objects by running "make clean", or otherwise install the header files as follows:
export INCLUDEDIR=~/foo/include
make install
by exporting the INCLUDEDIR environment variable before running the command, "logging.h" and "config.h" files get installed beneath the "~/foo/include" directory.
Change directory to the "lib" directory:
cd ~/devel/foo/lib
and create the Makefile file with the following contents:
clean:
rm -f *.so
install:
install -m 0755 -d $(ROOT)$(LIBDIR)
install -m 0755 *.so $(ROOT)$(LIBDIR)
dist:
we can see how the install target of the Makefile does work as follows:
export LIBDIR=~/foo/lib
make install
There is of course a cleanup target too - let's try it:
make clean
it cleans up the directory from every shared object file found.
Implementing the greet feature
I think that I cannot delay declaring the purpose of the "foo" application more, and so I hereby claim that
As a user I want a command that greets every time it is run. And I want it configurable so to be either kind or aggressive too.
So we must implement the greet feature: let's create the "greet" directory within the "src" directory:
cd ~/devel/foo/src
mkdir -m 755 greet
change to the "greet" directory:
cd greet
at first create the "greet.h" header file where we declare functions that implements the greet feature:
#ifndef GREET_H_
#define GREET_H_
void greet(struct settings *config, FILE *logfp);
#endif
then create the "greet.c" file with the code that implements it:
#include "../facilities/config.h"
#include "../facilities/logging.h"
#include <string.h>
void greet(struct settings *config, FILE *logfp){
if(strcmp(config->behavior,"kind")==0) {
printf("Hi dear, I'm foo app, glad to see you\n");
logmessage(logfp, "kindly greeting\n");
}
if(strcmp(config->behavior,"aggressive")==0) {
printf("I'm foo app, I already told you not to disturb me!\n");
logmessage(logfp, "aggressive greeting\n");
}
}
lastly we must create the Makefile that governs the build lifecycle of the files in this directory ("src/greet"):
all: foo
foo:
gcc -Wall -g -c -fPIC greet.c
gcc -shared greet.o -o ../../lib/libgreet.so
clean:
rm -f *.o
install:
install -m 0755 -d $(ROOT)$(INCLUDEDIR)/greet
install -m 0755 *.h $(ROOT)$(INCLUDEDIR)/greet
dist: clean
as you see we define the following targets:
- foo: compiles the sources generating object files (-c option) with Position-Independent Code (fPIC option) - this particular kind of code is necessary since it puts them into the libgreet.so shared object beneath the "lib" directory of the project ("~/devel/foo/lib")
- clean: deletes the built object files - that is cleanup the workspace
- install: install the header files into the desired path
- dist: an empty target that launches the "clean" target. it is unused here, but is required by the master Makefile we will develop more over.
same as we did before, if you are curious, we can now have a go as follows:
make
the outcome is the libgreet.so file into the "lib" directory of the project.
We can of course cleanup the directory from the built objects by running "make clean", otherwise install the header files as follows:
export INCLUDEDIR=~/foo/include
make install
by exporting the INCLUDEDIR environment variable before running the command, the "greet.h" header file gets installed beneath the "~/foo/include" directory.
Implementing the main object
It has now come the time to implement the main object of the foo application.
Change back to the parent directory ("src"):
cd ~/devel/foo/src
and create the "main.c" file with the following contents:
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include "facilities/config.h"
#include "facilities/logging.h"
#include "greet/greet.h"
int main (int argc, char *argv[]) {
struct settings config;
FILE *log;
log=openlog();
if(log==NULL) exit(EXIT_FAILURE);
if(loadconfig(&config,log)!=0) exit(EXIT_FAILURE);
greet(&config,log);
closelog(log);
exit(EXIT_SUCCESS);
}
Are you enjoying these high quality free contents on a blog without annoying banners? I like doing this for free, but I also have costs so, if you like these contents and you want to help keeping this website free as it is now, please put your tip in the cup below:
Even a small contribution is always welcome!
we of course still need create the Makefile that assembles the main application: create it with the following contents:
all: foo
foo:
$(MAKE) -C greet $(MAKECMDGOALS)
$(MAKE) -C facilities $(MAKECMDGOALS)
gcc -Wall -g -c main.c
gcc main.o -o ../bin/foo -L../lib -lgreet -lfacilities
clean:
$(MAKE) -C greet $(MAKECMDGOALS)
$(MAKE) -C facilities $(MAKECMDGOALS)
rm -f *.o
install:
$(MAKE) -C greet $(MAKECMDGOALS)
$(MAKE) -C facilities $(MAKECMDGOALS)
dist: clean
as you can see "foo" is the default target that:
- enters the "greet" and "facilities" directories and launches the nested Makefiles (lines 4-5)
- compiles the main program generating the "main.o" object adding debugging information (line 6)
- assembles the "main.o" object linking it with the "libgreet.so" and "libfacilities.so" shared libraries into the "foo" ELF binary file (line 7)
we are ready to build the whole "foo" application:
make
the "foo" executable binary file is put beneath the "bin" directory of the project; lets change to that directory:
cd ~/devel/foo/bin
and create the Makefile that manages its installation:
clean:
rm -f foo
install:
install -m 0755 -d $(ROOT)$(PREFIX)/bin
install -m 0755 foo $(ROOT)$(PREFIX)/bin
dist:
before trying to install it using the make install command, let's launch the foo application, just to see if it can even work:
./foo
the outcome may disappoint your expectations:
./foo: error while loading shared libraries: libgreet.so: cannot open shared object file: No such file or directory
this is not an error - it is simply complaining that we tried to launch the application without properly setting up the environment: foo has been linked to shared libraries, but they are not in the standard path indeed.
This means that we must set the LD_LIBRARY_PATH environment variable to the directory containing the "libgreet.so" and "libfacilities.so" files:
LD_LIBRARY_PATH=~/devel/foo/lib ./foo
it is still complaining, ... but this time for an error handled by the application:
Unable to open "../etc/foo.conf" file
so it is straightforward that we must create the "../etc/foo.conf" configuration file. Create it with the following contents:
behavior=kind
let's try to have a go again now:
LD_LIBRARY_PATH=~/devel/foo/lib ./foo
and this time it is magically working!
Hi dear, I'm foo app, glad to see you
so it is evident that the "etc" directory and the "etc/foo.conf" file are required to run the foo application. This is something those who install the foo application must be wary about.
But since we are kind, we can automate the installation for them by creating the Makefile that manages their installation right into each directory.
So let's create the Makefile beneath the "etc" directory:
install:
install -m 0755 -d $(ROOT)$(CONFDIR)
install -m 0644 foo.conf $(ROOT)$(CONFDIR)
be wary that the "foo" application also writes log messages to a file: in order to do a good job we must also create the Makefile that creates the "log" directory or, once installed, the application will not run complaining that it cannot open the log file for writing.
Simply create a Makefile with the following contents in the "log" directory:
install:
install -m 0755 -d $(ROOT)$(LOGDIR)
as you see the install target takes care of creating the log directory in the path specified by the LOGDIR environment variable.
Creating the Man Pages
Unless you are sadistic and love to see people doing reverse engineering to be able to run the applications you write, you must provide their documentation.
It is straightforward that a user may want the answers to questions like:
what's the file path of the configuration file?
what are the valid values of the "behavior" setting?
and so on.
Times ago it was fashionable to document command line tools creating a man page with the same name of the command itself: I'm a romantic and I fancy this habit.
Since the "foo" application is a command line tool, let's see how to create its man page.
Let's change to the "man" directory:
cd ~/devel/foo/man
Linux man pages are text files processed by the groff text formatter.
Instead of directly writing the man page, we create a template from which to generate the actual man page: this way we can easily replace the placeholders in the template with data such as the release version, so as to produce a man page that reflects the actual release version of the command line it refers to.
Let's create the "foo.template" file with the following contents:
\" Manpage for foo.
.\" Contact me@fake.tld to correct errors or typos.
.TH man 8 "16 Jan 2022" "__VERSION__" "foo man page"
.SH NAME
foo \- greet the user
.SH SYNOPSIS
foo
.SH DESCRIPTION
foo is the ultimate greeting utility.
.SH OPTIONS
foo does not take any options.
.SH FILES
settings can be specified by configuring __CONFIG_DIR__/foo.conf file
.SH SETTINGS
The following settings are implemented:
.IP \fBbehavior\fR
the behavior of greet to issue: valid values are "kind" or "aggressive"
.SH BUGS
No known bugs.
.SH AUTHOR
Marco Antonio Carcano (me@fake.tld)
to automate and ease the generation of the actual man page we rely on a Makefile with the following contents:
foo:
cp foo.template foo
sed -i "s/__VERSION__/${VERSION}/g;s+__CONFIG_DIR__+${CONFDIR}+" foo
clean:
rm -f foo
install:
install -m 0755 -d $(ROOT)$(MANDIR)
install -m 0644 foo $(ROOT)$(MANDIR)
dist:
let's try it and generate the foo man page - note that we must set some environment variable before:
VERSION=1.0 CONFDIR=../etc make
the outcome is the creation of the "foo" file - that is the actual man page derived from the template.
Let's have a look to it:
man ./foo
the output is as follows:
man(8) foo man page man(8)
NAME
foo - greet the user
SYNOPSIS
foo
DESCRIPTION
foo is the ultimate greeting utility.
OPTIONS
foo does not take any options.
FILES
settings can be specified by configuring ../etc/foo.conf file
SETTINGS
The following settings are implemented:
behavior
the behavior of greet to issue: valid values are "kind" or "aggressive"
BUGS
No known bugs.
AUTHOR
Marco Antonio Carcano (me@fake.tld)
1.0 16 Jan 2022 man(8)
Creating the Documentation
Man pages are certainly handy when working with a command line, but there are of course more modern and attractive documentation formats, such as HTML pages, MarkDown documents or PDF.
These documentation files are historically put beneath the "doc" directory: let's change to this directory:
cd ~/devel/foo/doc
now create the README file for the "foo" application:
This is the amazing foo app that can greet you better than any other application can do.
and of course let's create the Makefile file to install it:
install:
install -m 0755 -d $(ROOT)$(DOCDIR)
install -m 0644 README $(ROOT)$(DOCDIR)
sed -i "s+__CONFIGDIR__+$(CONFDIR)+" $(ROOT)$(DOCDIR)/README
this time we need just the install target.
Generating The Product Certificate
Although this is not mandatory, you can add a product certificate too: this is a certificate with custom metadata that can be exploited by the auto-attach feature of subscription-manager to guess what products are compatible with the installed operating system version, or when providing a criteria to automatically attach the matching products.
The RPM package must install it beneath the "/etc/pki/product" directory, into a PEM file with the numeric ID of the product as name. For example the file with the product certificate of "Red Hat Enterprise Linux" is 69.pem.
A product certificate contains the metadata that is stored using OIDs children of "1.3.6.1.4.1.2312.9.1": the first children number of the namespace is the numeric ID of the product (that marks the beginning of the product namespace).
For example, Red Hat Enterprise Linux has been assigned to the ID 69, and it's OID namespace is "1.3.6.1.4.1.2312.9.1.69".
Beneath the product namespace there are the following attributes
- Name: the name of the product, for example "Foo"
- Version: the version of the product, for example "1.0"
- Arch: the architecture family of the product, for example "x86_64"
- Tags: a comma-separated list of tags assigned to the product. These are often used to express affinities. For example "foo1-centos-8,centos-8"
- Brand Type: an optional attribute with the brand type
- Brand Name: an optional attribute with the brand name
In this example we suppose that the Product ID of the "Foo" product fetched from our Red Hat Network Satellite Server 6 (or of course Katello) is "656104112056".
Since every custom attribute we are about to add has its own OID, it is straightforward that to generate such a kind of certificate we need a dedicated openssl config file that lists all of them and instructs openssl on how to add them to the Certificate Signing Request (CSR), so that the signing Certification Authority can then copy them to the actual certificate.
As an example, let's create the "products.conf" file with the following contents:
It is straightforward that these are custom OIDs that must be added to the certificate.
So it is necessary to create an openssl configuration file that describes them and how to put them into the certificate signing request.
First we need to chan to the "cert" directory:
cd ~/devel/foo/cert
and create the "product.template" file with the following contents:
id_section = contents_oid
[ contents_oid ]
Name=1.3.6.1.4.1.2312.9.1.__PRODUCT_ID__.1
Version=1.3.6.1.4.1.2312.9.1.__PRODUCT_ID__.2
Arch=1.3.6.1.4.1.2312.9.1.__PRODUCT_ID__.3
Tags=1.3.6.1.4.1.2312.9.1.__PRODUCT_ID__.4
[ req ]
distinguished_name = req_distinguished_name
req_extensions = product
[ req_distinguished_name ]
commonName = Common Name
[ product ]
basicConstraints = CA:FALSE
1.3.6.1.4.1.2312.9.1.__PRODUCT_ID__.1 = ASN1:UTF8String:__PRODUCT_NAME__
1.3.6.1.4.1.2312.9.1.__PRODUCT_ID__.2 = ASN1:UTF8String:__VERSION__
1.3.6.1.4.1.2312.9.1.__PRODUCT_ID__.3 = ASN1:UTF8String:__ARCH__
1.3.6.1.4.1.2312.9.1.__PRODUCT_ID__.4 = ASN1:UTF8String:__TAGS__
we use it as the template from which to derive the "product.conf" openssl config file we use to generate the Certificate Signing Request for the product certificate.
Same way as we are doing with man pages, we exploit the Makefile to generate the "product.conf" file: create the Makefile with the following contents:
foo:
@cp product.template product.conf
@sed -i "s/__PRODUCT_NAME__/${PRODUCT_NAME}/g;s/__PRODUCT_ID__/${PRODUCT_ID}/g;s/__ARCH__/${ARCH}/g;s/__VERSION__/${VERSION}/g;s/__TAGS__/${PRODUCT_TAGS}/g" product.conf
@[ -f product.key ] || openssl genrsa -out product.key 4096
@openssl req -config product.conf -key product.key -new -sha256 -out ${PRODUCT_ID}.csr -subj '/CN=${PRODUCT_CN}'
@[ -f ${PRODUCT_ID}.crt ] || echo "################################################################"
@[ -f ${PRODUCT_ID}.crt ] || echo "Certificate Signing Request completed. You must now send the"
@[ -f ${PRODUCT_ID}.crt ] || echo "cert/${PRODUCT_ID}.csr file to your Certification Authority."
@[ -f ${PRODUCT_ID}.crt ] || echo "the certificate file you get back from them must be renamed into"
@[ -f ${PRODUCT_ID}.crt ] || echo "${PRODUCT_ID}.crt and put into the \"cert\" directory."
@[ -f ${PRODUCT_ID}.crt ] || echo "################################################################"
install:
install -m 0755 -d $(ROOT)$(CERTDIR)
install -m 0644 ${PRODUCT_ID}.crt $(ROOT)$(CERTDIR)/${PRODUCT_ID}.pem
dist:
as you can see there are two targets: "install" that installs the product certificate, and "foo" (the default target by the way) that generates:
- the "product.conf" openssl config file
- the RSA key that is used for the Certificate Signing Request for this product
- the Certificate Signing Request, that is a ".csr" file with the IP of the product as name
Once generated, the Certificate Signing Request file must be sent to the Corporate's Certification Authority to have then returned the signed X.509 certificate.
Just as an example, if the Corporate's CA relies on openssl and the Product ID is "656104112056", the certificate generation command is:
openssl ca -config openssl.conf -days 7000 -in 656104112056.csr -out 656104112056.crt
the certificate must then be added to the project, so that it is put among the files provided by the RPM package.
The certificate
- must be renamed so to have the same name of the Certificate Signing Request file, with the ".crt" extension
- must be put in the "cert" directory
Don't run it now, but please note that if you want to run this Makefile as we did before you need first to export the following environment variables:
- PRODUCT_NAME
- PRODUCT_ID
- ARCH
- TAGS
Same way, if you want to run the "install" target, you must at least export the CERTDIR variable.
Anyway do not launch it now: it will be executed as a whole when running the main Makefile of the project.
Create the Main Makefile Of the Project
You certainly have got that manually running all these Makefiles is not handy at all: indeed the project must be governed by a root Makefile, that when run takes care to launch the relevant nested Makefiles specifying the same target used to run it.
Change to the root directory of the project:
cd ~/devel/foo
and create the Makefile file with the following contents:
.EXPORT_ALL_VARIABLES:
ROOT ?=
VERSION ?= 0.1
PREFIX ?= ~/foo
CONFDIR ?= ${PREFIX}/etc
DOCDIR ?= ${PREFIX}/doc
INCLUDEDIR ?= ${PREFIX}/include
LIBDIR ?= ${PREFIX}/lib
LOGDIR ?= ${PREFIX}/log
MANDIR ?= ${PREFIX}/man
CERTDIR ?= ${PREFIX}/product
COMPANY_NAME ?= "Carcano SA"
PRODUCT_UID ?= "1fac-31230-aafe-1111"
PRODUCT_CN="${COMPANY_NAME} Product ID [${PRODUCT_UID}]"
PRODUCT_NAME ?= "Foo"
PRODUCT_ID ?= "656104112056"
PRODUCT_TAGS ?= "foo-1-centos8,centos-8"
ARCH ?= $$(uname -i)
all: foo
foo:
$(MAKE) -C src $(MAKECMDGOALS)
$(MAKE) -C man $(MAKECMDGOALS)
cd cert; make $(MAKECMDGOALS); cd ..
clean:
$(MAKE) -C src $(MAKECMDGOALS)
$(MAKE) -C bin $(MAKECMDGOALS)
$(MAKE) -C lib $(MAKECMDGOALS)
$(MAKE) -C man $(MAKECMDGOALS)
rm -f foo-${VERSION}.tar.gz
install:
$(MAKE) -C src $(MAKECMDGOALS)
$(MAKE) -C bin $(MAKECMDGOALS)
$(MAKE) -C lib $(MAKECMDGOALS)
$(MAKE) -C etc $(MAKECMDGOALS)
$(MAKE) -C doc $(MAKECMDGOALS)
$(MAKE) -C man $(MAKECMDGOALS)
$(MAKE) -C log $(MAKECMDGOALS)
$(MAKE) -C cert $(MAKECMDGOALS)
dist: clean
@rm -f log/foo.log
@tar czf foo-${VERSION}.tar.gz \
--transform "s,^,foo-${VERSION}/," \
src etc lib/Makefile bin/Makefile \
log doc man cert Makefile
this Makefile governs the whole project:
it declares all the targets required to manage the build cycle of the project:
- foo: build the whole application (the executable, the shared objects and the documentation) and generates the CSR of the product certificate
- install: install the application along with its config file and shared objects, the documentation and the header files necessary to who wants to extend it. In addition to that it creates the directory where to store the log file. Note that before running it you must have obtained the product certificate from your Certification Authority and put it into the "cert" directory, as explained before
- clean: cleanup the workspace
- dist: package the application along with its source files into a gzipped tarball
The root Makefile checks if any of the variables used in the targets of the nested Makefiles is already set and if any of them isn't it sets it with a reasonable default (lines 2-18).
It of course takes care to export them (line 1) so that they can be fetched by the nested Makefiles.
Let's cleanup the workspace:
make clean
We can now have a complete go:
let's start by building the application:
make
the output is as by the following (cut) snippet:
make -C src make[1]: Entering directory `/home/student/devel/foo/src' make -C greet make[2]: Entering directory `/home/student/devel/foo/src/greet' gcc -Wall -g -c -fPIC greet.c ... ... make[1]: Leaving directory `/home/student/devel/foo/man' cd cert; make ; cd .. make[1]: Entering directory `/home/student/devel/foo/cert' Generating RSA private key, 4096 bit long modulus ........++ ...................++ e is 65537 (0x10001) ################################################################ Certificate Signing Request completed. You must now send the cert/656104112056.csr file to your Certification Authority. the certificate file you get back from them must be renamed into 656104112056.crt and put into the "cert" directory. ################################################################
then let's install the application - the default install path is "~/foo"
make install
the application is now installed beneath "~/foo": we can launch it as follows:
cd ~/foo/bin
export LD_LIBRARY_PATH=../lib
./foo
the output is as follows:
Hi dear, I'm foo app
last but not least, ... let's get back to the project folder and package everything:
cd ~/devel/foo
make dist
the outcome is the foo-0.1.tar.gz gzipped tarball, where 0.1 is the version of the application.
Creating the RPM Package
Although tarball can be a convenient packaging format to exchange things between developers, we must strive to provide our software packaged using the native package manager of the Linux distribution. On Red Hat and derivatives, it is the Redhat Package Manager, broadly known as RPM.
A Very Quick Overview Of RPM
RPM manages the building and packaging as a whole within the following directory tree:
~/rpmbuild/
├── SOURCES
├── SPECS
├── BUILD
├── RPMS
└── SRPMS
where:
- SOURCES is the directory where archives containing the source files must be put. Archives can be tar archives, either compressed or not.
- SPECS is the directory that contains the ".spec" files: these are the files that contain all the necessary statements that describe the RPM package.
- BUILD is the directory used during the build process, where temporary files are stored, created, moved etc.
- RPMS is directory that holds the built RPM packages
- SRPMS is the directory that holds the ".src.rpm" packages: these are RPM packages that provide the archive containing the source files along with the SPEC file, so providing everything is needed to rebuild the RPM package.
Generate a stub SPEC file
First and foremost let's get back to the project main directory:
cd ~/devel/foo/RPM
RPM are built by having the rpmbuild command line utility reading the SPEC file: we can easily generate a stub SPEC file as follows:
rpmdev-newspec foo
the outcome is the "foo.spec" file: we use this stub and complete it with the missing settings that are necessary to package "foo" as RPM.
Configure the SPEC file
Edit the "RPM/foo.spec" until it looks like as follows:
%{!?_version: %define _version 0.1}
%{!?_prefixdir: %define _prefixdir /usr/local}
%{!?_docsdir: %define _docsdir /usr/share/doc/foo-%{_version}}
%{!?_includesdir: %define _includesdir /usr/include/foo}
%{!?_configdir: %define _configdir /etc/foo}
%{!?_libsdir: %define _libsdir /usr/lib64/foo}
%{!?_logsdir: %define _logsdir /var/log/foo}
%{!?_mansdir: %define _mansdir /usr/share/man/man8}
%{!?_certsdir: %define _certsdir /etc/pki/product}
%{!?_product_id: %define _product_id "656104112056" }
Name: foo
Version: %{_version}
Release: 1%{?dist}
Summary: Foo package
License: LGPL
URL: https://grimoire.carcano.ch
Source0: foo-%{_version}.tar.gz
BuildRequires: gcc make
%description
This is the amazing foo package that greats you every time you launch it
%package -n foo-devel
Summary: Foo package development files
%description -n foo-devel
These are the amazing foo package develpment files
%prep
%setup -q
%build
sed -i "s+#define CONFIG_FILE .*+#define CONFIG_FILE \"%{?_configdir}/foo.conf\"+" src/facilities/config.h
sed -i "s+#define LOGGING_FILE .*+#define LOGGING_FILE \"%{?_logsdir}/foo.log\"+" src/facilities/logging.h
make %{?_smp_mflags}
%install
rm -rf $RPM_BUILD_ROOT
export ROOT="%{?buildroot}"
export PREFIX="%{?_prefixdir}"
export DOCDIR="%{?_docsdir}"
export INCLUDEDIR="%{?_includesdir}"
export CONFDIR="%{?_configdir}"
export LIBDIR="%{?_libsdir}"
export LOGDIR="%{?_logsdir}"
export MANDIR="%{?_mansdir}"
export CERTDIR="%{?_certsdir}"
export PRODUCT_ID="%{?_product_id}"
export ARCH=%{_arch}
%make_install
cd ${RPM_BUILD_ROOT}%{?_libsdir}
ln -s libgreet.so libgreet.so.%{_version}
ln -s libfacilities.so libfacilities.so.%{_version}
mkdir -m 755 ${RPM_BUILD_ROOT}/etc/ld.so.conf.d
echo "%{?_libsdir}" > ${RPM_BUILD_ROOT}/etc/ld.so.conf.d/foo-%{_arch}.conf
mv ${RPM_BUILD_ROOT}/%{?_mansdir}/foo ${RPM_BUILD_ROOT}/%{?_mansdir}/foo.8
cd ${RPM_BUILD_ROOT}/%{?_mansdir}
gzip foo.8
%files
%{_configdir}/foo.conf
%{?_certsdir}/*.pem
%{_prefixdir}/bin/foo
/etc/ld.so.conf.d/foo-%{_arch}.conf
%doc %{_docsdir}/README
%{_libsdir}/libgreet.so*
%{_libsdir}/libfacilities.so*
%{?_mansdir}/foo.8.gz
%attr(0777 root root) %dir %{_logsdir}
%files -n foo-devel
%{_includesdir}/facilities/*.h
%{_includesdir}/greet/*.h
%post
ldconfig
%postun
ldconfig
%changelog
* Thu Feb 17 2022 Marco Antonio Carcano <me@mydomain.tld>
First release
The most interesting parts of this SPEC file are:
- setting and exporting the ROOT variable with the path of the buildroot RPM macro
- setting and exporting every environment variable used inside the Makefile so that instead of installing everything in the self-contained directory it installs everything directly on the root Linux standard filesystem (lines 42-50)
- setting and exporting the product ID environment certificate so to know the name of the certificate file to install (line 51)
- setting and exporting the architecture family environment variable (line 52)
- creating the necessary symlinks to the shared objects (lines 55-56)
- creating the file with the custom path of the shared objects (lines 57-58) and reloads ldconfig so to recreate its cache (%post and %postun sections)
- adjusting the man page into the format that is expected on the target version of the Linux distribution (lines 59-61)
Within the same directory ("RPM"), create the Makefile with the following contents:
dist:
@sed -i "s/%define _version .*/%define _version ${VERSION}}/" foo.spec
lastly, complete the "dist" target in the root Makefile so that it looks like as follows:
dist: clean
$(MAKE) -C RPM $(MAKECMDGOALS)
@ rm -f log/foo.log
@tar czf foo-${VERSION}.tar.gz \
--transform "s,^,foo-${VERSION}/," \
src etc lib/Makefile bin/Makefile \
log doc man cert RPM Makefile
install -m 0755 -d ~/rpmbuild/SOURCES
install -m 0755 -d ~/rpmbuild/SPECS
install -m 0644 foo-${VERSION}.tar.gz ~/rpmbuild/SOURCES
install -m 0644 RPM/foo.spec ~/rpmbuild/SPECS
rpmbuild -ba RPM/foo.spec
as you can see:
- it enters the RPM directory and launches the nested Makefile (line 2)
- same as before, it packs the sources within a gzipped tarball (lines 4-7)
- it creates the "~/rpmbuild/SOURCES" and "~/rpmbuild/SPECS" directories (lines 8-9 )
- it copies the gzipped tarball to "~/rpmbuild/SOURCES" (line 10)
- it copies the SPEC file to "~/rpmbuild/SPECS" (line 11)
- it launches the rpm building process (line 12)
With these additions, the "dist" target is now able to create the RPM and source RPM packages.
Building the RPM
Now that we have the SPEC file, and we added the rpmbuild directives to the root Makefile, we can easily build the RPM and source RPM as follows:
cd ~/devel/foo
make dist
the output is as by the following (cut) snippet:
make -C src dist
make[1]: Entering directory `/home/student/devel/foo/src'
make -C greet dist
make[2]: Entering directory `/home/student/devel/foo/src/greet'
rm -f *.o
make[2]: Leaving directory `/home/student/devel/foo/src/greet'
make -C facilities dist
...
Checking for unpackaged file(s): /usr/lib/rpm/check-files /home/student/rpmbuild/BUILDROOT/foo-0.1-1.el7.x86_64
Wrote: /home/student/rpmbuild/SRPMS/foo-0.1-1.el8.src.rpm
Wrote: /home/student/rpmbuild/RPMS/x86_64/foo-0.1-1.el8.x86_64.rpm
Wrote: /home/student/rpmbuild/RPMS/x86_64/foo-devel-0.1-1.el8.x86_64.rpm
Wrote: /home/student/rpmbuild/RPMS/x86_64/foo-debugsource-0.1-1.el8.x86_64.rpm
Wrote: /home/student/rpmbuild/RPMS/x86_64/foo-debuginfo-0.1-1.el8.x86_64.rpm
Executing(%clean): /bin/sh -e /var/tmp/rpm-tmp.Nwq79f
+ umask 022
+ cd /home/student/rpmbuild/BUILD
+ cd foo-0.1
+ /usr/bin/rm -rf /home/student/rpmbuild/BUILDROOT/foo-0.1-1.el8.x86_64
+ exit 0
as you see from the above snippet, the following files are created beneath /home/student/rpmbuild/RPMS/x86_64 directory:
- foo-0.1-1.el8.x86_64.rpm: RPM package with the foo application
- foo-devel-0.1-1.el8.x86_64.rpm: the SDK RPM package with the C headers necessary to who wish to develop integrations with the foo application
- foo-debuginfo-0.1-1.el8.x86_64.rpm: debugging information needed to provide human-readable names for binary code. This package must be installed only when debugging and intstalls beneath "/usr/lib/debug" directory *.debug files with the DWARF debugging information.
- foo-debugsource-0.1-1.el8.x86_64.rpm: source files used for compiling the binary code. They get installed into the "/usr/src/debug" directory. Note that this package is created as by Red Hat Enterprise Linux 8: on previous releases everything related to debug was put into the debuginfo RPM sub-package.
In addition to them, the foo-0.1-1.el8.src.rpm with the sources is written into the "~/rpmbuild/SRPMS" directory.
GPG Signing The RPM
Before publishing an RPM package into a repository from where people can download it, we must digitally sign it: by doing so, the rpm package management utility can verify that the signature matches one of the GPG public keys that have already been imported (that means "approved") into the RPM GPG keyring of the system .
When using installation tools such as yum, dnf, zipper and such, if the key used to sign the RPM package is missing, they can even automatically download it from the online repository and prompt us if we want to trust it - if we agree, the GPG key is installed and so RPM signed with that key are considered trusted too.
Generate The GPG Key
It is straightforward that to use cryptographic software we need a lot of random: if you have not installed it yet, install, start and enable to start at boot rngd:
sudo dnf install -y rng-tools
sudo systemctl start rngd
sudo systemctl enable rngd
If you are working with a Red Hat (or CentOS) version lower than 8, you must install also screen:
yum install -y screen
this is needed if you want to use gpg commands as a user you switched to using sudo, since in such a scenario gpg is not able to securely read the password you specify to unlock the key.
Signing is made using GNU Privacy Guard (GPG), so the very first thing is generating the GNU Privacy Guard (GPG) key pair used to sign the RPM packages.
If you are using Red Hat 8 or CentOS 8 (like me in this post), you can exploit both the --pinentry-mode and --full-gen-key command option, so let's create the GPG key pair by entering the following command:
gpg --expert --pinentry-mode loopback --full-gen-key
the --pinentry-mode provides a workaround to the trouble that when working as a user after switching to it using sudo gpg is not able to securely read the password specified to unlock the key.
Unfortunately the gpg version available on older Red Hat or CentOS releases does not provide this option nor --full-gen-key.
If you are working on any of them as user after switching to it using sudo, the workaround is launching a screen session - simply type:
screen
then create the GPG key pair by entering the following command:
gpg --expert --gen-key
This is the transcript of my answers to the questions:
Please select what kind of key you want:
(1) RSA and RSA (default)
(2) DSA and Elgamal
(3) DSA (sign only)
(4) RSA (sign only)
(7) DSA (set your own capabilities)
(8) RSA (set your own capabilities)
(9) ECC and ECC
(10) ECC (sign only)
(11) ECC (set your own capabilities)
(13) Existing key
(14) Existing key from card
Your selection? 1
RSA keys may be between 1024 and 4096 bits long.
What keysize do you want? (2048)
Requested keysize is 2048 bits
RSA keys may be between 1024 and 4096 bits long.
What keysize do you want for the subkey? (2048)
Requested keysize is 2048 bits
Please specify how long the key should be valid.
0 = key does not expire
= key expires in n days
w = key expires in n weeks
m = key expires in n months
y = key expires in n years
Key is valid for? (0) 10y
Key expires at Sat 25 Oct 2031 05:00:56 PM UTC
Is this correct? (y/N) y
GnuPG needs to construct a user ID to identify your key.
Real name: The Grimoire Of A Modern Linux Professional (CentOS8 x86_64)
Email address: ci@grimoire.carcano.ch
Comment:
You selected this USER-ID:
"The Grimoire Of A Modern Linux Professional (CentOS8 x86_64) <ci@grimoire.carcano.ch>"
Change (N)ame, (C)omment, (E)mail or (O)kay/(Q)uit? o
Let's confirm typing "o", type the desired password you want to use for unlocking the key and wait for everything to complete.
Let's now have a look at the generated keys:
gpg --list-keys
the output is as follows:
gpg: checking the trustdb
gpg: marginals needed: 3 completes needed: 1 trust model: pgp
gpg: depth: 0 valid: 1 signed: 0 trust: 0-, 0q, 0n, 0m, 0f, 1u
gpg: next trustdb check due at 2031-10-25
/home/grimoire/.gnupg/pubring.kbx
---------------------------------
pub rsa2048 2021-10-27 [SC] [expires: 2031-10-25]
2A6018DE3B4282CE229A897E5BA662C0EBD6F747
uid [ultimate] The Grimoire Of A Modern Linux Professional (CentOS8 x86_64) <ci@grimoire.carcano.ch>
sub rsa2048 2021-10-27 [E] [expires: 2031-10-25]
The public key related to the private one used to sign must be published on a repository that can be accessed by everybody who needs to install RPM packages signed by us: otherwise they have no way to check the signature.
This mean that we must export the public key as follows:
gpg --export -a ci@grimoire.carcano.ch > ~/RPM-GPG-KEY-grimoire-centos8-x86_64
and publish it on the repository.
Configuring RPM To Sign With GPG
The signing feature for the rpm command line utility is provided by the rpm-sign RPM package - let's install it as follows:
sudo dnf install -y rpm-sign
then we have to configure the RPM macros of the current user; since we can have several keys within our GPG keyring, we must specify which GPG key must be used to sign RPMs: here we use "ci@grimoire.carcano.ch", the GPG key we just created:
cat << \EOF >> ~/.rpmmacros
%_gpg_name <ci@grimoire.carcano.ch>
EOF
although this is optional, we can even specify the GPG command we use to sign along with its options - here I can specify the "--pinentry-mode loopback" since I'm using Red Hat 8 / CentOS 8:
cat << \EOF >> ~/.rpmmacros
%__gpg_sign_cmd %{__gpg} gpg --force-v3-sigs --no-armor --pinentry-mode loopback --no-secmem-warning -u "%{_gpg_name}" -sbo %{__signature_filename} --digest-algo sha256 %{__plaintext_filename}'
EOF
Sign the RPM packages
now we have setup everything and we are eventually ready to sign the packages - the rpm command is as follows:
rpm --addsign ~/rpmbuild/RPMS/x86_64/foo-0.1-1.el8.x86_64.rpm \
~/rpmbuild/RPMS/x86_64/foo-devel-0.1-1.el8.x86_64.rpm \
~/rpmbuild/RPMS/x86_64/foo-debugsource-0.1-1.el8.x86_64.rpm \
~/rpmbuild/RPMS/x86_64/foo-debuginfo-0.1-1.el8.x86_64.rpm \
~/rpmbuild/SRPMS/foo-0.1-1.el8.src.rpm
after typing the password to unlock the secret key for the signature, the RPM packages get signed.
We can of course add a "sign" target to the root Makefile of the project:
sign:
$(info -> Makefile: digitally signing the RPM packages...)
rpm --resign ~/rpmbuild/RPMS/x86_64/*.rpm\
~/rpmbuild/SRPMS/*.rpm
Now that you configured it into the Makefile, you can easily sign the package as follows:
make sign
right after typing the password to unlock the secret key for the signature, the RPM packages get signed.
Verify the Signature of the Package
Let's now pretend to be the ones that need to install the RPM package: since the delivered RPM package is signed, we must import the public key used to sign it into an RPM database:
sudo rpm --import ~/RPM-GPG-KEY-grimoire-centos8-x86_64
we are now ready to install the RPM package using "yum", "dnf" or even the simple "rpm" command tool.
If we are not interested into installing it, but we anyway wot to check the signature, simply type:
rpm --checksig ~/rpmbuild/RPMS/x86_64/foo-*.rpm \
~/rpmbuild/SRPMS/foo-*.rpm
the output is as follows:
/home/student/rpmbuild/RPMS/x86_64/foo-0.1-1.el8.x86_64.rpm: digests signatures OK
/home/student/rpmbuild/RPMS/x86_64/foo-debuginfo-0.1-1.el8.x86_64.rpm: digests signatures OK
/home/student/rpmbuild/RPMS/x86_64/foo-debugsource-0.1-1.el8.x86_64.rpm: digests signatures OK
/home/student/rpmbuild/RPMS/x86_64/foo-devel-0.1-1.el8.x86_64.rpm: digests signatures OK
/home/student/rpmbuild/SRPMS/foo-0.1-1.el8.src.rpm: digests signatures OK
the RPM packet is signed and the signature is valid if the word "signatures OK" appears at the end of the line.
Footnotes
Here it ends this tutorial on how to create and package a full featured C (or C++) project. You learned everything you must know so as to create a clean and tidy structure that helps to easily extend and maintain the project. In addition to that, you learned how to create man pages and to embed documentation with the distribution package. The install function lets it be installed on a standard linux filesystem using a privileged user, but also into a self contained directory so that unprivileged users can install it. Besides the legacy gzipped tar archive, you learned how to create and sign RPM packages and generate and provide the product certificate that can be exploited by the subscription-manager.
Felipe Guacache Hurtado says:
Marco Antonio,
First of all, thanks for publishing this article. I was an early adopter of linux back in the day in 1993 (kernel version 0.9x, TAMU distribution) and have seen with delight how it evolved into a major player in personal computing as well as enterprise use.
I have never seen such a complete discussion on building a linux application/package. You did not leave anything out, as far as I can tell.
Thanks for your selfless sharing of knowledge.
Best regards,
Felipe Guacache Hurtado
Marco Antonio Carcano says:
Hello Felipe. I wrote it with a little bit of sadness thinking about the years I was a little bit more than a teenager. In 1993 I was just fourteen, … so I started with Linux a couple of years after you (TAMU, … sound bloody crazy even only thinking to it). I’m really pleased you liked it, and honestly I wrote it hoping that it may help people younger than us, who had not the luck to live those magic years: I really love the modern stuff such as Docker, Kubernetes and such, but I really fear that youngest people are losing sight with the pillars of Linux. This is the reason I started my blog.