= For Developers In any Nix-based system the line between "configuration" and "development" is less of a line and more of a continuum. This section covers some topics further towards the latter end. == Writing modules and services It helps here to know NixOS! Liminix uses the NixOS module infrastructure code, meaning that everything that has been written for NixOS about the syntax, the type system, and the rules for combining configuration values from different sources is just as applicable here. === Services For the most part, for common use cases, we hope that Liminix modules provide service templates for all the services you will need, and you will only have to pass the right parameters to `+build+`. But if you're reading this then our hopes are in vain. To create a custom service of your own devising, use the [.title-ref]#oneshot# or [.title-ref]#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. [source,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 [source,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 (`+ppp0+`, or `+ppp1+` or `+ppp7+`) as an output so that a dependent service can reference it to set up a route, or to configure firewall rules. A service `+myservice+` should write its outputs as files in `+/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 `+in_outputs+` shell function in the `+up+` or `+run+` attributes of the service: [source,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 `+output+` to print the value of an output [source,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 `+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 `+modules/dhcp6c/acquire-wan-address.fnl+` [source,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 `+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. ===== Design considerations for outputs 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 evel of an advanced shell like Bash, let alone those of a real programming language. Note also that the Lua `+svc+` library only reads the first line of each output. === Modules Modules in Liminix conventionally live in `+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 https://nixos.org/manual/nixos/stable/#sec-writing-modules[Writing NixOS 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 Although you can define services "ad hoc" using `longrun` or `oneshot` <<_writing_services,as above>>, this approach has limitations if you're writing code intended for wider use. Services in the modules bundled with Liminix are implemented following a pattern we call "service templates": functions that accept a _type-checked_ attrset and return an appropriately configured service that can be assigned by the caller to a key in ``config.services``. 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+` [source,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 `+./service.nix+`) and the types of its parameters. [source,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 `+./service.nix+`: [source,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. === Emulated devices Unless your changes depend on particular hardware devices, you may want to test your new/changed module with one of the emulated "devices" which runn on your build machine using the free http://www.qemu.org[QEMU machine emulator]. They are * `qemu`(MIPS) * `qemu-armv7l`(32 bit ARM) * `qemu-aarch64` (64 bit ARM) This means you don't need to keep flashing or messing with U-Boot: it also enables testing against emulated network peers using https://wiki.qemu.org/Documentation/Networking#Socket[QEMU socket networking], which may be preferable to letting Liminix loose on your actual LAN. To build, [source,console] ---- nix-build -I liminix-config=path/to/your/configuration.nix --arg device "import ./devices/qemu" -A outputs.default ---- This creates a `+result/+` directory containing a `+vmlinux+` and a `+rootfs+`, and a shell script `+run.sh+` which invokes QEMU to run that kernel with that filesystem. It connects the Liminix serial console and the https://www.qemu.org/docs/master/system/monitor.html[QEMU monitor] to stdin/stdout. Use `^P` (not `^A`) to switch to the monitor. // FIXME should add a `connect.sh` script instead of requiring nix-shell here If you run with `+--background /path/to/some/directory+` as the first parameter, it will fork into the background and open Unix sockets in that directory for console and monitor. Use `+nix-shell --run connect-vm+` to connect to either of these sockets, and ^O to disconnect. [[qemu-networking]] ===== Networking VMs can network with each other using QEMU socket networking. We observe these conventions, so that we can run multiple emulated instances and have them wired up to each other in the right way: * multicast 230.0.0.1:1234 : access (interconnect between router and "isp") * multicast 230.0.0.1:1235 : lan * multicast 230.0.0.1:1236 : world (the internet) Any VM started by a `+run.sh+` script is connected to "lan" and "access". The emulated upstream (see below) runs PPPoE and is connected to "access" and "world". ===== Upstream connection In pkgs/routeros there is a derivation to install and configure https://mikrotik.com/software[Mikrotik RouterOS] as a PPPoE access concentrator connected to the `+access+` and `+world+` networks, so that Liminix PPPoE client support can be tested without actual hardware. This is made available as the `+routeros+` command in `+buildEnv+`, so you can do something like: .... mkdir ros-sockets nix-shell nix-shell$ routeros ros-sockets nix-shell$ connect-vm ./ros-sockets/console .... to start it and connect to it. Note that by default it runs in the background. It is connected to "access" and "world" virtual networks and runs a PPPoE service on "access" - so a Liminix VM with a PPPOE client can connect to it and thus reach the virtual internet. [ check, but pretty sure this is not the actual internet ] [.title-ref]#Liminix does not provide RouterOS licences and it is your own responsibility if you use this to ensure you're compliant with the terms of Mikrotik's licencing. It may be supplemented or replaced in time with configurations for RP-PPPoE and/or Accel PPP.# == Hardware hacking/porting to new device The steps to port to a new hardware device are largely undocumented at present (although this hasn't stopped people from figuring it out already). As an outline I would recommend * choose hardware that OpenWrt already supports, otherwise you will probably spend a lot of time writing kernel code. The OpenWrt kernel supports many network interfaces and other hardware for a lot of hardware boards that might only just about be able to boot Linux on a serial port if you stick to mainline Linux * work out how to get a serial console on it. You are unlikely to get working networking on your first go at boulding a kernel * find the most similar device in Liminiux and copy `devices/existing-similar-device` to `devices/cool-new-device` as a starting point * use the kernel configuration (`/proc/config.gz`) from OpenWrt as a reference for the kernel config you'll need to specify in `devices/cool-new-device/default.nix` * break it down into achieveable goals. Your first goal should be something that can TFTP boot the kernel as far as a running userland. Networking is harder, Wifi often much harder - it sometimes also depends on having working flash _even if_ you're TFTP booting because the driver expects to load wifi firmware or calibration data from the flash * ask on IRC! === TFTP [[tftpserver]] How you get your image onto hardware will vary according to the device, but is likely to involve taking it apart to add wires to serial console pads/headers, then using U-Boot to fetch images over TFTP. The OpenWrt documentation has a https://openwrt.org/docs/techref/hardware/port.serial[good explanation] of what you may expect to find on the device. [[tufted]] `tufted` is a rudimentary TFTP server which runs from the command line, has an allowlist for client connections, and follows symlinks, so you can have your device download images direct from the `+./result+` directory without exposing `+/nix/store/+` to the internet or mucking about copying files to `+/tftproot+`. If the permitted device is to be given the IP address 192.168.8.251 you might do something like this: [source,console] ---- nix-shell --run "tufted -a 192.168.8.251 result" ---- Now add the device and server IP addresses to your configuration: [source,nix] ---- boot.tftp = { serverip = "192.168.8.111"; ipaddr = "192.168.8.251"; }; ---- and then build the derivation for `+outputs.default+` or `+outputs.mtdimage+` (for which it will be an alias on any device where this is applicable). You should find it has created * `+result/firmware.bin+` which is the file you are going to flash * `+result/flash.scr+` which is a set of instructions to U-Boot to download the image and write it to flash after erasing the appropriate flash partition. NOTE: TTL serial connections typically have no form of flow control and so don't always like having massive chunks of text pasted into them - and U-Boot may drop characters while it's busy. So don't necessarily expect to copy-paste the whole of `+boot.scr+` into a terminal emulator and have it work just like that. You may need to paste each line one at a time, or even retype it. === Running from RAM For a faster edit-compile-test cycle, you can build a TFTP-bootable image which boots directly from RAM (using phram) instead of needing to be flashed first. In your device configuration add [source,nix] ---- imports = [ ./modules/tftpboot.nix ]; ---- and then build `+outputs.tftpboot+`. This creates a file `+result/boot.scr+`, which you can copy and paste into U-Boot to transfer the kernel and filesystem over TFTP and boot the kernel from RAM. [[bng]] === Networking You probably don't want to be testing a device that might serve DHCP, DNS and routing protocols on the same LAN as you (or your colleagues, employees, or family) are using for anything else, because it will interfere. You also might want to test the device against an "upstream" connection without having to unplug your regular home router from the internet so you can borrow the cable/fibre/DSL. `+bordervm+` is included for this purpose. You will need * a Linux machine with a spare (PCI or USB) ethernet device which you can dedicate to Liminix * an L2TP service such as https://www.aa.net.uk/broadband/l2tp-service/ You need to "hide" the Ethernet device from the host so that QEMU has exclusive use of it. For PCI this means configuring it for VFIO passthru; for USB you need to unload the module(s) it uses. I have this segment in my build machine's `configuration.nix` which you may be able to adapt: [source,nix] ---- boot = { kernelParams = [ "intel_iommu=on" ]; kernelModules = [ "kvm-intel" "vfio_virqfd" "vfio_pci" "vfio_iommu_type1" "vfio" ]; postBootCommands = '' # modprobe -i vfio-pci # echo vfio-pci > /sys/bus/pci/devices/0000:01:00.0/driver_override ''; blacklistedKernelModules = [ "r8153_ecm" "cdc_ether" ]; }; services.udev.extraRules = '' SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="8153", OWNER="dan" ''; ---- Then you can execute `+run-border-vm+` in a `+buildEnv+` shell, which starts up QEMU using the NixOS configuration in `+bordervm-configuration.nix+`. Inside the VM * your Liminix checkout is mounted under `+/home/liminix/liminix+` * TFTP is listening on the ethernet device and serving `+/home/liminix/liminix+`. The server IP address is 10.0.0.1 * a PPPOE-L2TP relay is running on the same ethernet card. When the connected Liminix device makes PPPoE requests, the relay spawns L2TPv2 Access Concentrator sessions to your specified L2TP LNS. Note that authentication is expected at the PPP layer not the L2TP layer, so the PAP/CHAP credentials provided by your L2TP service can be configured into your test device - bordervm doesn't need to know about them. To configure bordervm, you need a file called `+bordervm.conf.nix+` which you can create by copying and appropriately editing `+bordervm.conf-example.nix+` NOTE: If you make changes to the bordervm configuration after executing `+run-border-vm+`, you need to remove the `+border.qcow2+` disk image file otherwise the changes won't get picked up. == Contributing Patches welcome! Also bug reports, documentation improvements, experience reports/case studies etc etc all equally as welcome. * if you have an obvious bug fix, new package, documentation improvement or other uncontroversial small patch, send it straight in. * if you have a large new feature or design change in mind, please please _get in touch_ to talk about it before you commit time to implementing it. Perhaps it isn't what we were expecting, almost certainly we will have ideas or advice on what it should do or how it should be done. Liminix development is not tied to Github or any other particular forge. How to send changes: 1. Push your Liminix repo with your changes to a git repository somewhere on the Internet that I can clone from. It can be on Codeberg or Gitlab or Sourcehut or Forgejo or Gitea or Github or a bare repo in your own personal web space or any kind of hosting you like. 2. Email devel@liminix.org with the URL of the repo and the branch name, and we will take a look. If that's not an option, I’m also happy for you to send your changes direct to the list itself, as an incremental git bundle or using git format-patch. We'll work it out somehow. The main development repo for Liminix is hosted at , with a read-only mirror at . If you're happy to use Github then you can fork from the latter to make your changes, but please use the mailing list one of the approved routes to tell me about your changes because I don't regularly go there to check PRs. Remember that the <<_code_of_conduct>> applies to all Liminix spaces, and anyone who violates it may be sanctioned or expelled from these spaces at the discretion of the project leadership. === Nix language style This section describes some Nix language style points that we attempt to adhere to in this repo. Some are more aspirational than actual. * indentation and style is according to `nixfmt-rfc-style` * favour `+callPackage+` over raw `+import+` for calling derivations or any function that may generate one - any code that might need `+pkgs+` or parts of it. * prefer `+let inherit (quark) up down strange charm+` over `+with quark+`, in any context where the scope is more than a single expression or there is more than one reference to `+up+`, `+down+` etc. `+with pkgs; [ foo bar baz]+` is OK, `+with lib; stdenv.mkDerivation { ... }+` is usually not. * `++` is defined only when running tests, so don't refer to it in "application" code * the parameters to a derivation are sorted alphabetically, except for `+lib+`, `+stdenv+` and maybe other non-package "special cases" * where a `+let+` form defines multiple names, put a newline after the token `+let+`, and indent each name two characters * to decide whether some code should be a package or a module? Packages are self-contained - they live in `+/nix/store/eeeeeee-name+` and don't directly change system behaviour by their presence or absense. modules can add to `+/etc+` or `+/bin+` or other global state, create services, all that side-effecty stuff. Generally it should be a package unless it can't be. === Copyright The Nix code in Liminix is MIT-licenced (same as Nixpkgs), but the code it combines from other places (e.g. Linux, OpenWrt) may have a variety of licences. Copyright assignment is not expected: just like when submitting to the Linux kernel you retain the copyright on the code you contribute. === Automated builds Automated builds are run on each push to the main branch. This tests that (among other things) * every device image builds * the build for the “qemu” target is executed with a fake network upstream to test * PPPoE and DHCP service * hostap (wireless gateway) You can view the build output at https://build.liminix.org . The tests are defined in ci.nix. Unfortunately there's no (easy) way I can make _my_ CI infrastructure run _your_ code, other than merging it. But see <<_running_tests>> for how to exercise the same code locally on your machine. == Running tests You can run all of the tests by evaluating `+ci.nix+`, which is the input I use in Hydra. [source,console] ---- nix-build -I liminix=`pwd` ci.nix -A pppoe # run one job nix-build -I liminix=`pwd` ci.nix -A all # run all jobs ---- == Troubleshooting === Diagnosing unexpectedly large images Sometimes you can add a package and it causes the image size to balloon because it has dependencies on other things you didn't know about. Build the `+outputs.manifest+` attribute, which is a JSON representation of the filesystem, and you can run `+nix-store --query+` on it. [source,console] ---- nix-build -I liminix-config=path/to/your/configuration.nix \ --arg device "import ./devices/qemu" -A outputs.manifest \ -o manifest nix-store -q --tree manifest ----