This commit is contained in:
Robin Appelman 2024-01-27 17:50:50 +01:00
commit 333e23d329
11 changed files with 1638 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
target
*.bench
*.obj
result
.direnv
*.snap.new
*.dmp
config.toml
output

1195
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

18
Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "tasmota-backup"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
rust-version = "1.70.0"
[dependencies]
anyhow = "1.0.79"
clap = { version = "4.4.18", features = ["derive"] }
serde = { version = "1.0.195", features = ["derive"] }
toml = "0.8.8"
tokio = { version = "1.35.1", features = ["rt-multi-thread", "macros"] }
tasmota-mqtt-client = { version = "0.1", git = "https://github.com/icewind1991/tasmota-mqtt-client" }
md-5 = "0.10.6"
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
hex_fmt = "0.3.0"

60
flake.lock generated Normal file
View file

@ -0,0 +1,60 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1705309234,
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1706098335,
"narHash": "sha256-r3dWjT8P9/Ah5m5ul4WqIWD8muj5F+/gbCdjiNVBKmU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a77ab169a83a4175169d78684ddd2e54486ac651",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-23.11",
"type": "indirect"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

45
flake.nix Normal file
View file

@ -0,0 +1,45 @@
{
inputs = {
nixpkgs.url = "nixpkgs/nixos-23.11";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system: let
overlays = [
(import ./overlay.nix)
];
pkgs = (import nixpkgs) {
inherit system overlays;
};
in rec {
packages = rec {
inherit (pkgs) tasmota-backup;
default = tasmota-backup;
};
devShell = pkgs.mkShell {
nativeBuildInputs = with pkgs; [rustc cargo bacon cargo-edit cargo-outdated clippy];
};
}
)
// {
overlays.default = import ./overlay.nix;
nixosModules.default = {
pkgs,
config,
lib,
...
}: {
imports = [./module.nix];
config = lib.mkIf config.services.tasmota-backup.enable {
nixpkgs.overlays = [self.overlays.default];
services.tasmota-backup.package = lib.mkDefault pkgs.tasmota-backup;
};
};
};
}

100
module.nix Normal file
View file

@ -0,0 +1,100 @@
{
config,
lib,
pkgs,
...
}:
with lib; let
format = pkgs.formats.toml {};
configFile = format.generate "tasmota-backup.toml" {
output.target = cfg.outputPath;
mqtt = {
inherit (cfg.mqtt) hostname port username;
"password-file" = cfg.mqtt.passwordFile;
};
device."password-file" = cfg.devicePasswordFile;
};
cfg = config.services.tasmota-backup;
in {
options.services.tasmota-backup = {
enable = mkEnableOption "Log archiver";
outputPath = mkOption {
type = types.str;
description = "Directory to save the backups into";
};
mqtt = mkOption {
type = types.submodule {
options = {
hostname = mkOption {
type = types.str;
description = "MQTT hostname";
};
port = mkOption {
type = types.port;
default = 1883;
description = "MQTT port";
};
username = mkOption {
type = types.str;
description = "MQTT username";
};
passwordFile = mkOption {
type = types.str;
description = "File containing the MQTT password";
};
};
};
description = "MQTT options";
};
devicePasswordFile = mkOption {
type = types.str;
description = "File containing the device password";
};
package = mkOption {
type = types.package;
defaultText = literalExpression "pkgs.tasproxy";
description = "package to use";
};
};
config = mkIf cfg.enable {
systemd.services."tasmota-backup" = {
wantedBy = ["multi-user.target"];
serviceConfig = {
ExecStart = "${cfg.package}/bin/tasmota-backup ${configFile}";
Restart = "on-failure";
DynamicUser = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
NoNewPrivileges = true;
PrivateDevices = true;
ProtectClock = true;
CapabilityBoundingSet = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
SystemCallArchitectures = "native";
ProtectKernelModules = true;
RestrictNamespaces = true;
MemoryDenyWriteExecute = true;
ProtectHostname = true;
LockPersonality = true;
ProtectKernelTunables = true;
RestrictAddressFamilies = "AF_INET AF_INET6";
RestrictRealtime = true;
ProtectProc = "noaccess";
SystemCallFilter = ["@system-service" "~@resources" "~@privileged"];
IPAddressDeny = "multicast";
PrivateUsers = true;
ProcSubset = "pid";
RuntimeDirectory = "tasmota-backup";
RestrictSUIDSGID = true;
};
};
};
}

3
overlay.nix Normal file
View file

@ -0,0 +1,3 @@
final: prev: {
tasmota-backup = final.callPackage ./package.nix {};
}

24
package.nix Normal file
View file

@ -0,0 +1,24 @@
{
stdenv,
rustPlatform,
libsodium,
pkg-config,
lib,
}: let
inherit (lib.sources) sourceByRegex;
src = sourceByRegex ./. ["Cargo.*" "(src)(/.*)?"];
in
rustPlatform.buildRustPackage rec {
pname = "tasmota-backup";
version = "0.1.0";
inherit src;
cargoLock = {
lockFile = ./Cargo.lock;
outputHashes = {
"tasmota-mqtt-client-0.1.0" = "sha256-Azs9F825oU4ME+KwJIniLHGzVEBHJJws3faJLdBYoAA=";
};
};
}

88
src/config.rs Normal file
View file

@ -0,0 +1,88 @@
use anyhow::{Context, Result};
use serde::Deserialize;
use std::fs::read_to_string;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize)]
pub struct Config {
pub mqtt: MqttConfig,
pub device: DeviceConfig,
pub output: OutputConfig,
#[serde(default = "default_discovery_time")]
pub discovery_time: u64,
}
fn default_discovery_time() -> u64 {
1
}
impl Config {
pub fn load(path: &Path) -> Result<Config> {
let raw = read_to_string(path)
.with_context(|| format!("Failed to load config from {}", path.display()))?;
toml::from_str(&raw)
.with_context(|| format!("Failed to parse config file {} as toml", path.display()))
}
}
#[derive(Debug, Deserialize)]
pub struct OutputConfig {
pub target: PathBuf,
}
#[derive(Debug, Deserialize)]
pub struct MqttConfig {
pub hostname: String,
#[serde(default = "default_port")]
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
}
fn default_port() -> u16 {
1883
}
impl MqttConfig {
pub fn credentials(&self) -> Option<(&str, &str)> {
self.username.as_deref().zip(self.password.as_deref())
}
}
#[derive(Debug, Deserialize)]
pub struct RawMqttConfig {
pub hostname: String,
pub port: u16,
pub username: Option<String>,
#[serde(flatten)]
pub password: Option<PasswordConfig>,
}
#[derive(Debug, Deserialize)]
pub struct DeviceConfig {
#[serde(flatten)]
pub password: PasswordConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum PasswordConfig {
Raw {
password: String,
},
File {
#[serde(rename = "password-file")]
password_file: String,
},
}
impl PasswordConfig {
/// Get the token either directly from the config or through the token file
pub fn get(&self) -> Result<String> {
match self {
PasswordConfig::Raw { password } => Ok(password.clone()),
PasswordConfig::File { password_file } => Ok(read_to_string(password_file)
.with_context(|| format!("Failed to read password from {password_file}"))?),
}
}
}

95
src/main.rs Normal file
View file

@ -0,0 +1,95 @@
use crate::config::Config;
use anyhow::{Context, Result};
use clap::Parser;
use hex_fmt::HexFmt;
use md5::{Digest, Md5};
use std::fs::{write, File};
use std::io::copy;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tasmota_mqtt_client::TasmotaClient;
use tokio::time::{sleep, timeout};
use tracing::{error, info};
mod config;
#[derive(Debug, Parser)]
struct Args {
config: PathBuf,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let args = Args::parse();
let config = Config::load(&args.config)?;
let device_password = config.device.password.get()?;
let client = TasmotaClient::connect(
&config.mqtt.hostname,
config.mqtt.port,
config.mqtt.credentials(),
)
.await?;
info!("waiting for device discovery");
// wait for discovery messages from mqtt
sleep(Duration::from_secs(config.discovery_time)).await;
let devices = client.current_devices();
info!("found {} devices", devices.len());
for device in devices {
let result = timeout(
Duration::from_secs(15),
download(&client, &device, &config.output.target, &device_password),
)
.await;
let result = result
.with_context(|| format!("Timeout while downloading config for {device}"))
.and_then(|res| res);
if let Err(e) = result {
error!(device = device, error = %e, "Failed to download config for {device}");
}
}
Ok(())
}
async fn download(
client: &TasmotaClient,
device: &str,
target_dir: &Path,
device_password: &str,
) -> Result<()> {
let file = client.download_config(&device, &device_password).await?;
let target_path = target_dir.join(&file.name);
let existing_hash = target_path
.exists()
.then(|| {
hash_file(&target_path).with_context(|| {
format!(
"failed to calculate checksum of existing file {}",
target_path.display()
)
})
})
.transpose()?;
if existing_hash != Some(file.md5) {
write(&target_path, file.data.as_ref()).with_context(|| {
format!("failed save downloaded config to {}", target_path.display())
})?;
info!(device = device, file = file.name, hash = %HexFmt(file.md5), "device config saved")
} else {
info!(device = device, file = file.name, hash = %HexFmt(file.md5), "device config unchanged")
}
Ok(())
}
#[tracing::instrument]
fn hash_file(path: &Path) -> Result<[u8; 16]> {
let mut file = File::open(path)?;
let mut hasher = Md5::new();
copy(&mut file, &mut hasher)?;
Ok(hasher.finalize().into())
}