liminix/doc/configuration.rst

483 lines
17 KiB
ReStructuredText

.. _configuration:
Configuration
#############
There are many things you can specify in a configuration, but these
are the ones you most commonly need to change:
* which services (processes) to run
* what packages to install
* permitted users and groups
* Linux kernel configuration options
* Busybox applets
* filesystem layout
Modules
*******
**Modules** are a means of abstraction which allow "bundling"
of configuration options related to a common purpose or theme. For
example, the ``dnsmasq`` module defines a template for a dnsmasq
service, ensures that the dnsmasq package is installed, and provides a
dnsmasq user and group for the service to run as. The ``ppp`` module
defines a service template and also enables various PPP-related kernel
configuration.
Not all modules are included in the configuration by default, because
that would mean that the kernel (and the Busybox binary providing
common CLI tools) was compiled with many unnecessary bells and whistles
and therefore be bigger than needed. (This is not purely an academic concern
if your device has little flash storage). Therefore, specifying a
service is usually a two-step process. For example, to add an NTP
service you first add :file:`modules/ntp` to your ``imports`` list,
then you create a service by calling
:code:`config.system.service.ntp.build { .... }` with the appropriate
service-dependent configuration parameters.
.. code-block:: nix
let svc = config.system.service;
in {
# ...
imports = [
./modules/ntp
# ....
];
config.services.ntp = svc.ntp.build {
pools = { "pool.ntp.org" = ["iburst"]; };
makestep = { threshold = 1.0; limit = 3; };
};
Merely including the module won't define the service on its own: it
only creates the template in ``config.system.service.foo`` and you
have to create an actual service using the template. This is an
intentional choice to allow the creation of multiple
differently-configured services based on the same template - perhaps
e.g. when you have multiple networks (VPNs etc) in different trust
domains, or you want to run two SSH daemons on different ports.
(For the background to this, please refer to the :doc:`architecture decision record <adr/module-system>`)
.. tip:: Liminix modules should be quite familiar (but also different)
if you already know how to use NixOS modules. We use the
NixOS module infrastructure code, meaning that you should
recognise the syntax, the type system, the rules for
combining configuration values from different sources. We
don't use the NixOS modules themselves, because the
underlying system is not similar enough for them to work.
.. _configuration-services:
Services
********
In Liminix a service is any kind of long-running task or process on
the system, that is managed (started, stopped, and monitored) by a
service supervisor. A typical SOHO router might have services to
* answer DHCP and DNS requests from the LAN
* provide a wireless access point
* connect using PPPoE or L2TP to an upstream network
* start/stop the firewall
* enable/disable IP packet forwarding
* mount filesystems
(Some of these might not be considered services using other
definitions of the term: for example, this L2TP process would be a
"client" in the client/server classification; and enabling packet
forwarding doesn't require any long-lived process - just a setting to
be toggled. However, there is value in being able to use the same
abstractions for all the things to manage them and specify their
dependency relationships - so in Liminix "everything is a service")
The service supervision system enables service health monitoring,
restart of unhealthy services, and failover to "backup" services when
a primary service fails or its dependencies are unavailable. The
intention is that you have a framework in which you can specify policy
requirements like "ethernet wan dhcp-client should be restarted if it
crashes, but if it can't start because the hardware link is down, then
4G ppp service should be started instead".
Any attribute in `config.services` will become part of the default set
of services that s6-rc will try to bring up. Services are usually
started at boot time, but **controlled services** are those that are
required only in particular contexts. For example, a service to mount
a USB backup drive should run only when the drive is attached to the
system. Liminix currently implements three kinds of controlled service:
* "uevent-rule" service controllers use sysfs/uevent to identify when
particular hardware devices are present, and start/stop a controlled
service appropriately.
* the "round-robin" service controller is used for service failover:
it allows you to specify a list of services and runs each of them
in turn until it exits, then runs the next.
* the "health-check" service wraps another service, and runs a "health
check" command at regular intervals. When the health check fails,
indicating that the wrapped service is not working, it is terminated
and allowed to restart.
Runtime secrets (external vault)
================================
Secrets (such as wifi passphrases, PPP username/password, SSH keys,
etc) that you provide as literal values in :file:`configuration.nix`
are processed into into config files and scripts at build time, and
eventually end up in various files in the (world-readable)
:file:`/nix/store` before being baked into a flashable image. To
change a secret - whether due to a compromise, or just as part of to a
routine key rotation - you need to rebuild the configuration and
potentially reflash the affected devices.
To avoid this, you may instead use a "secrets service", which is a
mechanism for your device to fetch secrets from a source external to
the Nix store, and create at runtime the configuration files and
scripts that start the services which require them.
Not every possible parameter to every possible service is configurable
using a secrets service. Parameters which can be configured this way
are those with the type ``liminix.lib.types.replacable``. At the time
this document was written, these include:
* ppp (pppoe and l2tp): ``username``, ``password``
* ssh: ``authorizedKeys``
* hostapd: all parameters (most likely to be useful for ``wpa_passphrase``)
To use a runtime secret for any of these parameters:
* create a secrets service to specify the source of truth for secrets
* use the :code:`outputRef` function in the service parameter to specify the secrets service and path
For example, given you had an HTTPS server hosting a JSON file with the structure
.. code-block:: json
"ssh": {
"authorizedKeys": {
"root": [ "ssh-rsa ....", "ssh-rsa ....", ... ]
"guest": [ "ssh-rsa ....", "ssh-rsa ....", ... ]
}
}
you could use a :file:`configuration.nix` fragment something like this
to make those keys visible to ssh:
.. code-block:: nix
services.secrets = svc.secrets.outboard.build {
name = "secret-service";
url = "http://10.0.0.1/secrets.json";
username = "secrets";
password = "liminix";
interval = 30; # minutes
dependencies = [ config.services.lan ];
};
services.sshd = svc.ssh.build {
authorizedKeys = outputRef config.services.secrets "ssh/authorizedKeys";
};
There are presently two implementations of a secrets service:
Outboard secrets (HTTPS)
------------------------
This service expects a URL to a JSON file containing all the secrets.
You may specify a username and password along with the URL, which are
used if the file is password-protected (HTTP Basic
authentication). Note that this is not a protection against a
malicious local user: the username and password are normal build-time
parameters so will be readable in the Nix store. This is a mitigation
against the URL being accidentally discovered due to e.g. a log file
or error message on the server leaking.
Tang secrets (encrypted local file)
-----------------------------------
Aternatively, secrets may be stored locally on the device, in a file
that has been encrypted using `Tang <https://github.com/latchset/tang>`_.
Tang is a server for binding data to network presence.
This sounds fancy, but the concept is simple. You have some data, but you only want it to be available when the system containing the data is on a certain, usually secure, network.
.. code-block:: nix
services.secrets = svc.secrets.tang.build {
name = "secret-service";
path = "/run/mnt/usbstick/secrets.json.jwe";
interval = 30; # minutes
dependencies = [ config.services.mount-usbstick ];
};
The encryption uses the
same scheme/algorithm as `Clevis <https://github.com/latchset/clevis>`_ : you may use the `Clevis instructions <https://github.com/latchset/clevis?tab=readme-ov-file#pin-tang>`_ to
encrypt the file on another host and then copy it to your Liminix
device, or you can use :command:`tangc encrypt` to encrypt directly on
the device. (That latter approach may pose a chicken/egg problem if
the device needs secrets to boot up and run the services you are
relying on in order to login).
Writing services
================
For the most part, for common use cases, hopefully the services you
need will be defined by modules and you will only have to pass the
right parameters to ``build``.
Should you need to create a custom service of your own devising, use
the `oneshot` or `longrun` functions:
* a "longrun" service is the "normal" service concept: it has a
``run`` action which describes the process to start, and it watches
that process to restart it if it exits. The process should not
attempt to daemonize or "background" itself, otherwise s6-rc will think
it died. Whatever it prints to standard output/standard error
will be logged.
.. code-block:: nix
config.services.cowsayd = pkgs.liminix.services.longrun {
name = "cowsayd";
run = "${pkgs.cowsayd}/bin/cowsayd --port 3001 --breed hereford";
# don't start this until the lan interface is ready
dependencies = [ config.services.lan ];
}
* a "oneshot" service doesn't have a process attached. It consists of
``up`` and ``down`` actions which are bits of shell script that
are run at the appropriate points in the service lifecycle
.. code-block:: nix
config.services.greenled = pkgs.liminix.services.oneshot {
name = "greenled";
up = ''
echo 17 > /sys/class/gpio/export
echo out > /sys/class/gpio/gpio17/direction
echo 0 > /sys/class/gpio/gpio17/value
'';
down = ''
echo 0 > /sys/class/gpio/gpio17/value
'';
}
Services may have dependencies: as you see above in the ``cowsayd``
example, it depends on some service called ``config.services.lan``,
meaning that it won't be started until that other service is up.
Service outputs
===============
Outputs are a mechanism by which a service can provide data which may
be required by other services. For example:
* the DHCP client service can expect to receive nameserver address
information as one of the fields in the response from the DHCP
server: we provide that as an output which a dependent service for a
stub name resolver can use to configure its upstream servers.
* a service that creates a new network interface (e.g. ppp) will
provide the name of the interface (:code:`ppp0`, or :code:`ppp1` or
:code:`ppp7`) as an output so that a dependent service can reference
it to set up a route, or to configure firewall rules.
A service :code:`myservice` should write its outputs as files in
:file:`/run/services/outputs/myservice`: you can look around this
directory on a running Liminix system to see how it's used currently.
Usually we use the :code:`in_outputs` shell function in the
:command:`up` or :command:`run` attributes of the service:
.. code-block:: shell
(in_outputs ${name}
for i in lease mask ip router siaddr dns serverid subnet opt53 interface ; do
(printenv $i || true) > $i
done)
The outputs are just files, so technically you can read them using
anything that can read a file. Liminix has two "preferred"
mechanisms, though:
One-off lookups
---------------
In any context that ends up being evaluated by the shell, use
:code:`output` to print the value of an output
.. code-block:: nix
services.defaultroute4 = svc.network.route.build {
via = "$(output ${services.wan} address)";
target = "default";
dependencies = [ services.wan ];
};
Continuous updates
------------------
The downside of using shell functions in downstream service startup
scripts is that they only run when the service starts up: if a service
output *changes*, the downstream service would have to be restarted to
notice the change. Sometimes this is OK but other times the downstream
has no other need to restart, if it can only get its new data.
For this case, there is the :code:`anoia.svc` Fennel library, which
allows you to write a simple loop which is iterated over whenever a
service's outputs change. This code is from
:file:`modules/dhcp6c/acquire-wan-address.fnl`
.. code-block:: fennel
(fn update-addresses [wan-device addresses new-addresses exec]
;; run some appropriate "ip address [add|remove]" commands
)
(fn run []
(let [[state-directory wan-device] arg
dir (svc.open state-directory)]
(accumulate [addresses []
v (dir:events)]
(update-addresses wan-device addresses
(or (v:output "address") []) system))))
The :code:`output` method seen here accepts a filename (relative
to the service's output directory), or a directory name. It
returns the first line of that file, or for directories it
returns a table (Lua's key/value datastructure, similar to
a hash/dictionary) of the outputs in that directory.
Output design considerations
----------------------------
For preference, outputs should be short and simple, and not require
downstream services to do complicated parsing in order to use them.
Shell commands in Liminix are run using the Busybox shell which
doesn't have the niceties of an advanced shell like Bash let alone
those of a real programming language.
Note also that the Lua :code:`svc` library only reads the first line
of each output.
Module implementation
*********************
Modules in Liminix conventionally live in
:file:`modules/somename/default.nix`. If you want or need to
write your own, you may wish to refer to the
examples there in conjunction with reading this section.
A module is a function that accepts ``{lib, pkgs, config, ... }`` and
returns an attrset with keys ``imports, options config``.
* ``imports`` is a list of paths to the other modules required by this one
* ``options`` is a nested set of option declarations
* ``config`` is a nested set of option definitions
The NixOS manual section `Writing NixOS Modules
<https://nixos.org/manual/nixos/stable/#sec-writing-modules>`_ is a
quite comprehensive reference to writing NixOS modules, which is also
mostly applicable to Liminix except that it doesn't cover
service templates.
Service templates
=================
To expose a service template in a module, it needs the following:
* an option declaration for ``system.service.myservicename`` with the
type of ``liminix.lib.types.serviceDefn``
.. code-block:: nix
options = {
system.service.cowsay = mkOption {
type = liminix.lib.types.serviceDefn;
};
};
* an option definition for the same key, which specifies where to
import the service template from (often :file:`./service.nix`)
and the types of its parameters.
.. code-block:: nix
config.system.service.cowsay = config.system.callService ./service.nix {
address = mkOption {
type = types.str;
default = "0.0.0.0";
description = "Listen on specified address";
example = "127.0.0.1";
};
port = mkOption {
type = types.port;
default = 22;
description = "Listen on specified TCP port";
};
breed = mkOption {
type = types.str;
default = "British Friesian"
description = "Breed of the cow";
};
};
Then you need to provide the service template itself, probably in
:file:`./service.nix`:
.. code-block:: nix
{
# any nixpkgs package can be named here
liminix
, cowsayd
, serviceFns
, lib
}:
# these are the parameters declared in the callService invocation
{ address, port, breed} :
let
inherit (liminix.services) longrun;
inherit (lib.strings) escapeShellArg;
in longrun {
name = "cowsayd";
run = "${cowsayd}/bin/cowsayd --address ${address} --port ${builtins.toString port} --breed ${escapeShellArg breed}";
}
.. tip::
Not relevant to module-based services specifically, but a common
gotcha when specifiying services is forgetting to transform "rich"
parameter values into text when composing a command for the shell
to execute. Note here that the port number, an integer, is
stringified with ``toString``, and the name of the breed,
which may contain spaces, is
escaped with ``escapeShellArg``
Types
=====
All of the NixOS module types are available in Liminix. These
Liminix-specific types also exist in ``pkgs.liminix.lib.types``:
* ``service``: an s6-rc service
* ``interface``: an s6-rc service which specifies a network
interface
* ``serviceDefn``: a service "template" definition
In the future it is likely that we will extend this to include other
useful types in the networking domain: for example; IP address,
network prefix or netmask, protocol family and others as we find them.