Configuration files and directories

The root system bus is started with a --config /etc/syndicate/boot command-line argument, which causes it to execute configuration scripts in that directory. In turn, the boot directory contains instructions for loading configuration from other locations on the filesystem.

This section will examine the layout of the configuration scripts and directories.

The boot layer

The files in /etc/syndicate/boot define the boot layer.

Console getty

The first thing the boot layer does, in 001-console-getty.pr, is start a getty on /dev/console:

<require-service <daemon console-getty>>
<daemon console-getty "getty 0 /dev/console">

Ad-hoc execution of programs

Next, in 010-exec.pr, it installs a handler that responds to messages requesting ad-hoc execution of programs:

?? <exec ?argv ?restartPolicy> [
  let ?id = timestamp
  let ?facet = facet
  let ?d = <temporary-exec $id $argv>
  <run-service <daemon $d>>
  <daemon $d { argv: $argv, readyOnStart: #f, restart: $restartPolicy }>
  ? <service-state <daemon $d> complete> [$facet ! stop]
  ? <service-state <daemon $d> failed>   [$facet ! stop]
]

If the restart policy is not specified, it is defaulted to on-error:

?? <exec ?argv> ! <exec $argv on-error>

"Milestone" pseudo-services

Then, in 010-milestone.pr, it defines how to respond to a request to run a "milestone" pseudo-service:

? <run-service <milestone ?m>> [
  <service-state <milestone $m> started>
  <service-state <milestone $m> ready>
]

The definition is trivial—when requested, simply declare success—but useful in that a "milestone" can be used as a proxy for a configuration state that other services can depend upon.

Concretely, milestones are used in two places at present: a core milestone declares that the core layer of services is ready, and a network milestone declares that initial network configuration is complete.

Synthesis of service state "up"

The definition of ServiceState includes ready, for long-running service programs, and complete, for successful exit (exit status 0) of "one-shot" service programs. In 010-service-state-up.pr, we declare an alias up that is asserted in either of these cases:

? <service-state ?x ready> <service-state $x up>
? <service-state ?x complete> <service-state $x up>

Loading of "core" and "services" layers

The final tasks of the boot layer are to load the "core" and "service" layers, respectively.

Services declared in the "core" layer are automatically marked as dependencies of the <milestone core> pseudo-service, and those declared in the "services" layer are automatically marked as depending on <milestone core>.

services layer core layer eudev docker hostname modem milestone core network ntpd depend on milestone core depended on by milestone core machine dataspace sshd userSettings wifi

The core layer loader

For the core layer, in 020-load-core-layer.pr, a configuration watcher is started, monitoring /etc/syndicate/core for scripts defining services to place into the layer. Instead of passing an unattenuated reference to $config to the configuration watcher, an attenuation expression rewrites require-service assertions into require-core-service assertions:

let ?sys = <* $config [
  <rewrite <require-service ?s> <require-core-service $s>>
  <filter _>
]>

<require-service <config-watcher "/etc/syndicate/core" {
  config: $sys
  gatekeeper: $gatekeeper
  log: $log
}>

Then, require-core-service is given meaning:

? <require-core-service ?s> [
  <depends-on <milestone core> <service-state $s up>>
  <require-service $s>
]

The services layer loader

The services layer is treated similarly in 030-load-services.pr, except require-basic-service takes the place of require-core-service, and the configuration watcher isn't started until <milestone core> is ready. Any require-basic-service assertions are given meaning as follows:

? <require-basic-service ?s> [
  <depends-on $s <service-state <milestone core> up>>
  <require-service $s>
]

The core layer: /etc/syndicate/core

The files in /etc/syndicate/core define the core layer.

The configdirs.pr script brings in scripts in /run and /usr/local analogues of the core config directory:

<require-service <config-watcher "/run/etc/syndicate/core" $.>>
<require-service <config-watcher "/usr/local/etc/syndicate/core" $.>>

The eudev.pr script runs a udevd instance and, once it's ready, starts an initial scan:

<require-service <daemon eudev>>
<daemon eudev ["/sbin/udevd", "--children-max=5"]>

<require-service <daemon eudev-initial-scan>>
<depends-on <daemon eudev-initial-scan> <service-state <daemon eudev> up>>
<daemon eudev-initial-scan <one-shot "
  echo '' > /proc/sys/kernel/hotplug &&
  udevadm trigger --type=subsystems --action=add &&
  udevadm trigger --type=devices --action=add &&
  udevadm settle --timeout=30
">>

The hostname.pr script simply sets the machine hostname:

<require-service <daemon hostname>>
<daemon hostname <one-shot "hostname $(cat /etc/hostname)">>

Finally, the machine-dataspace.pr script declares a fresh, empty dataspace, and asserts a reference to it in a "well-known location" for use by other services later:

let ?ds = dataspace
<machine-dataspace $ds>

The services layer: /etc/syndicate/services

The files in /etc/syndicate/services define the services layer.

The configdirs.pr script brings in /run and /usr/local service definitions, analogous to the same file in the core layer:

<require-service <config-watcher "/run/etc/syndicate/services" $.>>
<require-service <config-watcher "/usr/local/etc/syndicate/services" $.>>

Networking core

The network.pr defines the <milestone network> pseudo-service and starts a number of ancillary services for generically monitoring and configuring system network interfaces.

First, <daemon interface-monitor> is a small Python program, required by <milestone network>, using Netlink sockets to track changes to interfaces and interface state. It speaks the Syndicate network protocol on its standard input and output, and publishes a service object which expects a reference to the machine dataspace defined earlier:

<require-service <daemon interface-monitor>>
<depends-on <milestone network> <service-state <daemon interface-monitor> ready>>
<daemon interface-monitor {
  argv: "/usr/lib/synit/interface-monitor"
  protocol: application/syndicate
}>
? <machine-dataspace ?machine> [
  ? <service-object <daemon interface-monitor> ?cap> [
    $cap {
      machine: $machine
    }
  ]
]

The interface-monitor publishes assertions describing interface presence and state to the machine dataspace. The network.pr script responds to these assertions by requesting configuration of an interface once it reaches a certain state. First, all interfaces are enabled when they appear and disabled when they disappear:

  $machine ? <interface ?ifname _ _ _ _ _ _> [
    $config [
      ! <exec ["ip" "link" "set" $ifname "up"]>
      ?- ! <exec ["ip" "link" "set" $ifname "down"] never>
    ]
  ]

Next, a DHCP client is invoked for any "normal" (wired-ethernet-like) interface in "up" state with a carrier:

  $machine ? <interface ?ifname _ normal up up carrier _> [
    $config <configure-interface $ifname <dhcp>>
  ]
  $machine ? <interface ?ifname _ normal up unknown carrier _> [
    $config <configure-interface $ifname <dhcp>>
  ]

  $config ? <configure-interface ?ifname <dhcp>> [
    <require-service <daemon <udhcpc $ifname>>>
  ]
  $config ? <run-service <daemon <udhcpc ?ifname>>> [
    <daemon <udhcpc $ifname> ["udhcpc" "-i" $ifname "-fR" "-s" "/usr/lib/synit/udhcpc.script"]>
  ]

We use a custom udhcpc script which modifies the default script to give mobile-data devices a sensible routing metric.

The final pieces of network.pr are static configuration of the loopback interface:

<configure-interface "lo" <static "127.0.0.1/8">>
? <configure-interface ?ifname <static ?ipaddr>> [
  ! <exec ["ip" "address" "add" "dev" $ifname $ipaddr]>
  ?- ! <exec ["ip" "address" "del" "dev" $ifname $ipaddr] never>
]

and conditional publication of a default-route record, allowing services to detect when the internet is (nominally) available:

  $machine ? <route ?addressFamily default _ _ _ _> [
    $config <default-route $addressFamily>
  ]

Wifi & Mobile Data

Building atop the networking core, wifi.pr and modem.pr provide the necessary support for wireless LAN and mobile data interfaces, respectively.

When interface-monitor detects presence of a wireless LAN interface, wifi.pr reacts by starting wpa_supplicant for the interface along with a small Python program, wifi-daemon, that acts as a client to wpa_supplicant, adding and removing networks and network configuration according to selected-wifi-network assertions in the machine dataspace.

  $machine ? <interface ?ifname _ wireless _ _ _ _> [
    $config [
      <require-service <daemon <wpa_supplicant $ifname>>>
      <depends-on
        <daemon <wifi-daemon $ifname>>
        <service-state <daemon <wpa_supplicant $ifname>> up>>
      <require-service <daemon <wifi-daemon $ifname>>>
    ]
  ]

  $config ? <run-service <daemon <wifi-daemon ?ifname>>> [
    <daemon <wifi-daemon $ifname> {
      argv: "/usr/lib/synit/wifi-daemon"
      protocol: application/syndicate
    }>
    ? <service-object <daemon <wifi-daemon $ifname>> ?cap> [
      $cap {
        machine: $machine
        ifname: $ifname
      }
    ]
  ]

  $config ? <run-service <daemon <wpa_supplicant ?ifname>>> [
    <daemon <wpa_supplicant $ifname> [
      "wpa_supplicant" "-Dnl80211,wext" "-C/run/wpa_supplicant" "-i" $ifname
    ]>
  ]

The other tasks performed by wifi.pr are to request DHCP configuration for available wifi interfaces:

  $machine ? <interface ?ifname _ wireless up up carrier _> [
    $config <configure-interface $ifname <dhcp>>
  ]

and to relay selected-wifi-network records from user settings (described below) into the machine dataspace, for wifi-daemon instances to pick up:

  $config ? <user-setting <?s <selected-wifi-network _ _ _>>> [ $machine += $s ]

Turning to modem.pr, which is currently hard-coded for Pinephone devices, we see two main blocks of config. The simplest just starts the eg25-manager daemon for controlling the Pinephone's Quectel modem, along with a simple monitoring script for restarting it if and when /dev/EG25.AT disappears:

<daemon eg25-manager "eg25-manager">
<depends-on <daemon eg25-manager> <service-state <daemon eg25-manager-monitor> up>>
<daemon eg25-manager-monitor "/usr/lib/synit/eg25-manager-monitor">

The remainder of modem.pr handles cellular data, configured via the qmicli program.

<require-service <qmi-wwan "/dev/cdc-wdm0">>
<depends-on <qmi-wwan "/dev/cdc-wdm0"> <service-state <daemon eg25-manager> up>>

When the user settings mobile-data-enabled and mobile-data-apn are both present, it responds to qmi-wwan service requests by invoking qmi-wwan-manager, a small shell script, for each particular device and APN combination:

? <user-setting <mobile-data-enabled>> [
  ? <user-setting <mobile-data-apn ?apn>> [
    ? <run-service <qmi-wwan ?dev>> [
      <require-service <daemon <qmi-wwan-manager $dev $apn>>>
    ]
  ]
]
? <run-service <daemon <qmi-wwan-manager ?dev ?apn>>> [
  <daemon <qmi-wwan-manager $dev $apn> ["/usr/lib/synit/qmi-wwan-manager" $dev $apn]>
]

(Because qmicli is sometimes not well behaved, there is also code in modem.pr for restarting it in certain circumstances when it gets into a state where it reports errors but does not terminate.)

Simple daemons

A few simple daemons are also started as part of the services layer.

The docker.pr script starts the docker daemon, but only once the network configuration is available:

<require-service <daemon docker>>
<depends-on <daemon docker> <service-state <milestone network> up>>
<daemon docker "/usr/bin/dockerd --experimental 2>/var/log/docker.log">

The ntpd.pr script starts an NTP daemon, but only when an IPv4 default route exists:

<require-service <daemon ntpd>>
<depends-on <daemon ntpd> <default-route ipv4>>
<daemon ntpd "ntpd -d -n -p pool.ntp.org">

Finally, the sshd.pr script starts the OpenSSH server daemon after ensuring both that the network is available and that SSH host keys exist:

<require-service <daemon sshd>>
<depends-on <daemon sshd> <service-state <milestone network> up>>
<depends-on <daemon sshd> <service-state <daemon ssh-host-keys> complete>>
<daemon sshd "/usr/sbin/sshd -D">
<daemon ssh-host-keys <one-shot "ssh-keygen -A">>

User settings

A special folder, /etc/syndicate/user-settings, acts as a persistent database of assertions relating to user settings, including such things as wifi network credentials and preferences, mobile data preferences, and so on. The userSettings.pr script sets up the programs responsible for managing the folder.

The contents of the folder itself are managed by a small Python program, user-settings-daemon, which responds to requests arriving via the $config dataspace by adding and removing files containing assertions in /etc/syndicate/user-settings.

let ?settingsDir = "/etc/syndicate/user-settings"
<require-service <daemon user-settings-daemon>>
<daemon user-settings-daemon {
  argv: "/usr/lib/synit/user-settings-daemon"
  protocol: application/syndicate
}>
? <service-object <daemon user-settings-daemon> ?cap> [
  $cap {
    config: $config
    settingsDir: $settingsDir
  }
]

Each such file is named after the SHA-1 digest of the canonical form of the assertion it contains. For example, /etc/syndicate/user-settings/8814297f352be4ebbff19137770e619b2ebc5e91.pr contains <mobile-data-enabled>.

The files in /etc/syndicate/user-settings are brought into the main config dataspace by way of a rewriting configuration watcher:

let ?settings = <* $config [ <rewrite ?item <user-setting $item>> ]>
<require-service <config-watcher $settingsDir { config: $settings }>>

Every assertion from /etc/syndicate/user-settings is wrapped in a <user-setting ...> record before being placed into the main $config dataspace.