Introduction

Dux is an automation engine turned into a Rust library. By automation, we mean orchestration / configuration management (call it an Ansible-like tool), basically something that ensures a given host meets a certain state.

Objective

The goal of the Dux project is to let developers easily "build their own Ansible" and integrate it in a already existing codebase.

Just like you would import the tonic crate in your code and start using it to make gRPC calls to support your business logic, you can import Dux in your Rust project and adapt its usage to your situation.

You want the legacy way (one binary running on a master node and having remote root access to everything through SSH) ? You can build that.
You prefer not to open ports and SSH servers everywhere and let hosts regularly fetch a configuration from some git repo somewhere and apply it to themselves ? You can also built that.
You need to handle large numbers of hosts and want to scale ? You can split the work between controllers and workers, put a message broker in the middle and have many workers behind it handling your hosts. And if you already have a message broker deployed somewhere, just use this one instead of deploying a new one. You are more of a gRPC fan ? No problem. Serialize the work and move it as a Protobuf String !

This book

This book consists of 2 main parts :

  • the engine : the dux crate (usage, types, ...)
  • examples of how to use it in different scenarios

The Dux crate

Duxcore

Principle

Based on Rust's type system, the workflow is as follows :

  1. Get a task list : what is the expected state of the managed hosts ? This step produces a TaskList struct.
  2. Get a hosts list : which hosts are under the scope of this task list ? This step produces a HostList struct.
  3. Generate Jobs : a Job represents a host and allows to track what happens to this host. It contains everything needed to handle the host and apply the expected state.
  4. Dry run or directly Apply the tasklist on the host by leveraging the Job

Usage

Import the crate

cargo add duxcore

Now let's perform the usual example : setup a webserver (but, this time, right from your Rust code !)

use duxcore::prelude::*;

fn main() {

    // First we need to define what the expected state of the target host is.
    let my_tasklist = r#"---
- name: Let's install a web server !
  steps:
    - name: First, we test the connectivity and authentication with the host.
      ping:
      
    - name: Then we can install the package...
      with_sudo: true
      apt:
        package: '{{ package_name }}'
        state: present
        
    - name: ... and start & enable the service.
      with_sudo: true
      service:
        name: '{{ service_name }}'
        state: started
        enabled: true

    - name: What date is it on this host by the way ?
      register: host_date
      command:
        content: date +%Y-%m-%d" "%Hh%M

    - name: Let's see...
      debug:
        msg: 'date: {{ host_date.output }}'
        
        "#;

    // Then we create a 'Job'.
    let mut my_job = Job::new();

    // We set who the target host of this Job is, and how to connect to it.
    my_job
        .set_address("10.20.0.203")
        .set_connection(HostConnectionInfo::ssh2_with_key_file("dux", "./controller_key")).unwrap();
    
    // We give it some context and the task list.
    my_job
        .add_var("package_name", "apache2")
        .add_var("service_name", "apache2")
        .set_tasklist_from_str(my_tasklist, TaskListFileType::Yaml).unwrap()
    ;
    // We can finally apply the task list to this host.
    my_job.apply();

    // Let's see the result.
    println!("{}", my_job.display_pretty());
}

This is the basic workflow of Dux. It is up to you to parallelize, distribute the work, display the results in some web interface or send them in a RabbitMQ queue... Whatever suits you best ! The whole point is to let you adapt this automation engine to the context of your already-existing infrastructure. Adapt the tool to the job !

TaskList

Examples

Features

Register

Produce a variable out of the result of a step and use it after.

---
- name: Retrieve the current working directory
  steps:
    - name: Print working directory
      register: working_directory
      command:
        content: pwd

    - name: Display this information
      debug:
        msg: {{ working_directory.output }}

Variables

Dux uses Tera as its template engine.

Modules

Modules available so far

(alphabetized)

ModuleDescription
aptManage packages on Debian-like distributions
debugDisplay useful information for debugging purposes
commandRun a single shell command on the controlled host
dnfManage packages on Fedora-like distributions (no difference with yum)
lineinfileManipulate lines in a file (add, delete)
pingTest SSH connectivity with remote host
serviceManage services on the controlled host
yumManage packages on Fedora-like distributions (no difference with dnf)

Apt

Handle packages on Debian-based distributions (Debian, Ubuntu, Linux Mint, Kali, Raspberry Pi OS...)

Parameters

ParameterTypeValueDescription
packageString<package-name>Name of the package to handle
stateStringpresent|absentExpected state of the package
upgradeBooltrue|falseDo we upgrade the system or not

Examples

---
- name: Handle packages on Debian-based distributions
  steps:
    - name: Upgrade the whole system
      with_sudo: true
      apt:
        upgrade: true

    - name: Install some package (git)
      with_sudo: true
      apt:
        package: git
        state: present

    - name: Remove some package
      with_sudo: true
      apt:
        package: git
        state: absent

Command

Display useful information for debugging purposes.

Parameters

ParameterTypeValueDescription
msgString<any message>Message to be displayed (including variables)

Examples

---
- name: Retrieve the current working directory
  steps:
    - name: Print working directory
      register: working_directory
      command:
        content: pwd

    - name: Display this information
      debug:
        msg: {{ working_directory.output }}

Command

Run a single shell command on the controlled host.

Parameters

ParameterTypeValueDescription
contentString<shell command to run>Content of the shell command to run

Examples

---
- name: Run some commands on the host
  steps:
    - name: List files in some directory
      command:
        content: ls -lh /tmp

    - name: Run the command with privileges
      with_sudo: true
      command:
        content: ls -lh /root

LineInFile

Manipulate lines in a file (add, delete). The file needs to exist before calling this module.

Parameters

ParameterTypeValueDescription
filepathString<path-to-file>Path of the file which will be edited
lineString<Content-of-line>Content of the line to consider
stateStringpresent|absentIs the line expected to be found or not
positionStringtop|bottom|anywhere|<line-number>Where is the line expected (or not) to be found

Examples

---
- name: Edit some files
  steps:
    - name: Add a line at the top
      lineinfile:
        filepath: /path/to/my/file
        line: the first line
        state: present
        position: top

    - name: Add a line at the 2nd place
      lineinfile:
        filepath: /path/to/my/file
        line: 2nd line
        state: present
        position: 2

    - name: Add a line at the bottom
      lineinfile:
        filepath: /path/to/my/file
        line: the last line
        state: present
        position: bottom

    - name: Remove all occurences of a line based on its content
      lineinfile:
        filepath: /path/to/my/file
        line: the content expected not to be present at all
        state: absent

Ping

Test connectivity with the target host (localhost, SSH...etc)

Parameters

No parameter for this module

Examples

---
- name: Test connectivity
  steps:
    - name: Test connectivity
      ping:

Service

Handle packages on Debian-based distributions (Debian, Ubuntu, Linux Mint, Kali, Raspberry Pi OS...)

Parameters

ParameterTypeValueDescription
nameString<service-name>Name of the service to handle
stateStringstarted|stoppedExpected state of the service
enabledBooltrue|falseIs the service expected to be enabled ?

Examples

---
- name: Handle services on the host
  steps:
    - name: Start a service
      with_sudo: true
      service:
        name: apache2
        state: started

    - name: Stop a service
      with_sudo: true
      service:
        name: apache2
        state: started

    - name: Start and enable a service
      with_sudo: true
      service:
        name: apache2
        state: started
        enabled: true

    - name: Disable a service (without stopping it)
      with_sudo: true
      service:
        name: apache2
        enabled: false

Yum / Dnf

Handle packages on Fedora-based distributions (Fedora, CentOS, RHEL, Amazon Linux...)

Parameters

ParameterTypeValueDescription
packageString<package-name>Name of the package to handle
stateStringpresent|absentExpected state of the package
upgradeBooltrue|falseDo we upgrade the system or not

Examples

With Yum

---
- name: Handle packages on Fedora-based distributions
  steps:
    - name: Upgrade the whole system
      with_sudo: true
      yum:
        upgrade: true

    - name: Install some package (git)
      with_sudo: true
      yum:
        package: git
        state: present

    - name: Remove some package
      with_sudo: true
      yum:
        package: git
        state: absent

With Dnf

---
- name: Handle packages on Fedora-based distributions
  steps:
    - name: Upgrade the whole system
      with_sudo: true
      dnf:
        upgrade: true

    - name: Install some package (git)
      with_sudo: true
      dnf:
        package: git
        state: present

    - name: Remove some package
      with_sudo: true
      dnf:
        package: git
        state: absent

Prepare a host to be controlled

On controlled host

As for other automation tools, Dux needs an account to use on the controlled host. Let's create a dux user on the controlled host and give it some privileges :

# Create user (set password interactively)
sudo adduser dux

# Add user to sudo group
sudo usermod -aG sudo dux

# Create a sudoers file for this user
echo "dux ALL = (root) NOPASSWD:ALL" | sudo tee /etc/sudoers.d/dux

On controller host

The ideal is to have a SSH passwordless connection :

# Generate a SSH key (no passphrase for the example)
ssh-keygen -t ed25519 -f controller_key -N "" -q

# Have this key allowed on the controlled host
ssh-copy-id -i controller_key.pub dux@<controlled host address>

Standard

Project link

schema

Usage

dux -t <tasklist.yaml> -l <hostlist.yaml> -k <SSH private key> -u <username>

with tasklist.yaml

- name: Let's install a web server !
  steps:
    - name: First, we test the connectivity and authentication with the host.
      ping:
      
    - name: Then we can install the package...
      with_sudo: true
      apt:
        package: '{{ package_name }}'
        state: present
        
    - name: ... and start & enable the service.
      with_sudo: true
      service:
        name: '{{ service_name }}'
        state: started
        enabled: true

and hostlist.yaml

vars:
  package_name: apache2
  service_name: apache2

hosts:
  - 10.20.0.203
  - 10.20.0.204

Output example

{
  "jobs": [
    {
      "host": "10.20.0.203",
      "timestamp_start": "2024-11-07T22:53:52.781400539+00:00",
      "timestamp_end": "2024-11-07T22:54:06.610757092+00:00",
      "final_status": "ApplySuccesful",
      "tasks": [
        {
          "name": "Let's install a web server !",
          "steps": [
            {
              "name": "First, we test the connectivity and authentication with the host.",
              "expected_state": {
                "ping": {}
              },
              "status": "ApplySuccessful"
            },
            {
              "name": "Then we can install the package...",
              "expected_state": {
                "apt": {
                  "state": "present",
                  "package": "apache2"
                }
              },
              "status": "ApplySuccessful"
            },
            {
              "name": "... and start & enable the service.",
              "expected_state": {
                "service": {
                  "name": "apache2",
                  "state": "started",
                  "enabled": true
                }
              },
              "status": "ApplySuccessful"
            }
          ]
        }
      ]
    },
    {
      "host": "10.20.0.204",
      "timestamp_start": "2024-11-07T22:53:52.742561538+00:00",
      "timestamp_end": "2024-11-07T22:54:04.799505739+00:00",
      "final_status": "ApplySuccesful",
      "tasks": [
        {
          "name": "Let's install a web server !",
          "steps": [
            {
              "name": "First, we test the connectivity and authentication with the host.",
              "expected_state": {
                "ping": {}
              },
              "status": "ApplySuccessful"
            },
            {
              "name": "Then we can install the package...",
              "expected_state": {
                "apt": {
                  "state": "present",
                  "package": "apache2"
                }
              },
              "status": "ApplySuccessful"
            },
            {
              "name": "... and start & enable the service.",
              "expected_state": {
                "service": {
                  "name": "apache2",
                  "state": "started",
                  "enabled": true
                }
              },
              "status": "ApplySuccessful"
            }
          ]
        }
      ]
    }
  ]
}

Agent

Project link

schema

The dux agent allows a host to regularly retrieve a tasklist from somewhere else and apply it to itself.

Pros and cons

Advantages

  • hosts handle themselves, no need for a centralized server which can reach every machine, be compatible with everything (execution environments), have root-level access to every machine...
  • hosts don't need to have an open port and an SSH server running behind it with some root-level user able to connect. Hosts stay simple clients as they should : they open some random port, initiate some connection protocol, get the tasklist from the server then close everything.
  • no need for complex key managment (have server's key authorized everywhere, or play with multiple keys...) which can drive beginners to turn to classic username/password system, using same password everywhere, or even worse...
  • you can replicate your tasklist to multiple servers, one per subnet for example, and have a better control over data flows through firewall rules and such

Disadvantages

  • in a way, you loose the initiative on your hosts : if you want a new tasklist to be applied immediatly, you have to configure agents to check tasklist updates very often, which can be a burden for your network if you have many hosts. A compromise has to be found between tasklist checking frequency and networking ressources.
  • you don't need to install an SSH server, have keys/hosts authorized and everything, but now you need to install the dux agent and configure it on every host. So, even if it's just installing a deb/rpm package and modifying a text file somewhere, preliminary work is still required.

TODO

Create a watcher which will be listening for hosts results. This watcher can be passed to agents as an endpoint where they can send their results, allowing the user to monitor which host did what. This way, we find back our centralized architecture but we avoid having SSH servers with root-level access everywhere.

Dux scalable implementation (TO BE UPDATED)

The Assignment type derives the serde traits Serialize and Deserialize.

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Assignment {
    ...
}
}

Then, using the serde_json crate, we can do this :

#![allow(unused)]
fn main() {
// Serialize
let serialized_assignment: String = serde_json::to_string(&assignment).unwrap();
// Send this String to another host via a TcpStream or anything else...

// A serialized Assignment is received.
// Deserialize it
let deserialized_assignment: Assignment = serde_json::from_str(serialized_assignment).unwrap();
}

It means the work can be split between multiple machines. One machine will generate an Assignment based on a TaskList and a HostList and send it to another one which will actually run it on the targeted host. The results can then be sent to a last machine which will display them as part of a web interface for instance.

As an example, in this Dux scalable version, the work is divided between controllers and workers, which a message broker in the middle. The Dux controller publishes Assignments on a RabbitMQ queue and consumes results on another queue. The Dux workers consume Assignments, run them and publish the results.

This allows to scale the operation when needed by increasing the number of workers and/or their capacity to multithread.

Architecture (TO BE UPDATED)

schema

Security

Each message is encrypted with password-based symmetric encryption. The message broker doesn't have access to the data.

Controller (TO BE UPDATED)

Source code available here

The Dux Scalable Controller generates Assignments (one per targeted host), publishes them on a RabbitMQ instance, and consumes results from this same instance.

Usage

dux-scalable-controller -t <tasklist.yaml> -l <hostlist.yaml> [-u <dux> -k <ssh_private_key>]

Configuration

TBD

Output example


    ██████╗ ██╗   ██╗██╗  ██╗
    ██╔══██╗██║   ██║╚═███╔═╝
    ██║  ██║██║   ██║  ███║ 
    ██████╔╝╚██████╔╝██╔╝ ██╗
    ╚═════╝  ╚═════╝ ╚═╝  ╚═╝
    🅲🅾🅽🆃🆁🅾🅻🅻🅴🆁

[2024-08-04T19:42:29Z INFO  simple_crypt] Encrypting
[2024-08-04T19:42:29Z INFO  dux_controller] 2A0E791A89693389EC0BE36BC97432626107998945A8C8B772DDC00578726DE8 : assignment sent to message broker
[2024-08-04T19:42:39Z INFO  simple_crypt] Decrypting
[2024-08-04T19:42:39Z INFO  dux_controller] 2A0E791A89693389EC0BE36BC97432626107998945A8C8B772DDC00578726DE8 : assignment result received
Host 10.20.0.204 : Matched

Worker (TO BE UPDATED)

Project link

Output example


    ██████╗ ██╗   ██╗██╗  ██╗
    ██╔══██╗██║   ██║╚═███╔═╝
    ██║  ██║██║   ██║  ███║ 
    ██████╔╝╚██████╔╝██╔╝ ██╗
    ╚═════╝  ╚═════╝ ╚═╝  ╚═╝ 
    🆆🅾🆁🅺🅴🆁                                 

[2024-08-04T19:42:37Z INFO  simple_crypt] Decrypting
[2024-08-04T19:42:37Z INFO  dux_worker] 2A0E791A89693389EC0BE36BC97432626107998945A8C8B772DDC00578726DE8 : Assignment received
[2024-08-04T19:42:38Z INFO  simple_crypt] Encrypting
[2024-08-04T19:42:38Z INFO  dux_worker] 2A0E791A89693389EC0BE36BC97432626107998945A8C8B772DDC00578726DE8 : Result sent to message broker

Distributed (TO BE UPDATED)

In the same spirit as the scalable implementation, in the Dux distributed version, the work is split between multiple machines. The assignments are generated by a controller and sent to workers which actually run them on targeted hosts.

The key difference is that Controller and workers directly talk to each other. They are declared from the beginning and the controller chooses which worker will handle an Assignment.

This implementation is less flexible than the scalable one but is simpler to use, as you don't need a message broker.

Architecture (TO BE UPDATED)

schema

Security

Using Ockam, all the data moving from one host (controller/worker) to the other is encrypted.

Controller (TO BE UPDATED)

Worker (TO BE UPDATED)

What's next

Todo list

  • Global : optimization (lots of clone out there...)
  • Global : error handling (lots of unwrap out there...)
  • HostList: introduce aliases and connection mode
  • HostList: add JSON format handling
  • TaskExec: modules need to produce a standardized JSON result, reusable directly by later steps ('register')
  • RabbitMQ: turn connections parts into a crate
  • RabbitMQ: add resiliency mechanisms (lost connection...etc)
  • Connection: introduce compatibility with QUIC, SSH3, other protocol ?
  • modules to handle Android and IOT devices ?
  • full ansible syntax compatibility ?
  • log generation : what is applied when on what, syslog interaction, ability to generate JSON content (for log aggregators for example)
  • Create a gRPC based example implementation
  • Create a watcher to get the results from the dux agents