switch config to env vars, add nix module

This commit is contained in:
Lewis Wynne 2026-04-09 14:24:46 +01:00
parent 996da6cf8b
commit 5bfba1b6ff
8 changed files with 206 additions and 32 deletions

7
Cargo.lock generated
View file

@ -245,6 +245,12 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]] [[package]]
name = "dptree" name = "dptree"
version = "0.3.0" version = "0.3.0"
@ -450,6 +456,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"chrono", "chrono",
"dotenvy",
"serde", "serde",
"teloxide", "teloxide",
"tokio", "tokio",

View file

@ -9,6 +9,7 @@ tokio = { version = "1", features = ["full"] }
teloxide = { version = "0.13", features = ["macros"] } teloxide = { version = "0.13", features = ["macros"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
toml = "0.8" toml = "0.8"
dotenvy = "0.15"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
chrono = "0.4" chrono = "0.4"
tracing = "0.1" tracing = "0.1"

View file

@ -1,6 +0,0 @@
listen = "127.0.0.1:8123"
data_dir = "./data"
site_title = "ily.rs"
site_url = "https://ily.rs"
telegram_bot_token = "REPLACE_ME"
telegram_chat_id = 0

View file

@ -8,7 +8,7 @@
}; };
outputs = { self, nixpkgs, crane, flake-utils, ... }: outputs = { self, nixpkgs, crane, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system: (flake-utils.lib.eachDefaultSystem (system:
let let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
craneLib = crane.mkLib pkgs; craneLib = crane.mkLib pkgs;
@ -22,5 +22,7 @@
devShells.default = craneLib.devShell { devShells.default = craneLib.devShell {
packages = with pkgs; [ cargo rustc rust-analyzer ]; packages = with pkgs; [ cargo rustc rust-analyzer ];
}; };
}); })) // {
nixosModules.default = ./module.nix;
};
} }

117
module.nix Normal file
View file

@ -0,0 +1,117 @@
{ config, lib, pkgs, ... }:
let
inherit (lib) mkOption mkEnableOption types mkIf mkMerge;
cfg = config.services.guestbook;
in
{
options.services.guestbook = {
enable = mkEnableOption "guestbook service";
package = mkOption {
type = types.package;
description = "The guestbook package to use.";
};
port = mkOption {
type = types.port;
default = 8123;
description = "Port to listen on (binds to 127.0.0.1).";
};
dataDir = mkOption {
type = types.str;
default = "/srv/guestbook/data";
description = "Directory for guestbook entry files.";
};
siteTitle = mkOption {
type = types.str;
default = "guestbook";
description = "Site title shown in nav and page title.";
};
siteUrl = mkOption {
type = types.str;
description = "Base URL of the main site (for absolute nav links).";
};
telegramChatId = mkOption {
type = types.int;
description = "Telegram chat ID for moderation messages.";
};
telegramBotTokenFile = mkOption {
type = types.path;
description = "Path to a file containing the Telegram bot token.";
};
user = mkOption {
type = types.str;
default = "guestbook";
description = "User to run the service as.";
};
group = mkOption {
type = types.str;
default = "guestbook";
description = "Group to run the service as.";
};
caddy = {
enable = mkEnableOption "Caddy reverse proxy for guestbook";
domain = mkOption {
type = types.str;
description = "Domain for the Caddy virtual host.";
};
};
};
config = mkIf cfg.enable (mkMerge [
{
systemd.services.guestbook = {
description = "Guestbook for ${cfg.siteTitle}";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
BOOK_PORT = toString cfg.port;
BOOK_DATA_DIR = cfg.dataDir;
BOOK_SITE_TITLE = cfg.siteTitle;
BOOK_SITE_URL = cfg.siteUrl;
BOOK_TELEGRAM_CHAT_ID = toString cfg.telegramChatId;
};
serviceConfig = {
Type = "simple";
ExecStartPre = "+${pkgs.writeShellScript "guestbook-prepare" ''
mkdir -p ${cfg.dataDir}/entries
chown -R ${cfg.user}:${cfg.group} ${cfg.dataDir}
''}";
Restart = "on-failure";
User = cfg.user;
Group = cfg.group;
ReadWritePaths = [ cfg.dataDir ];
};
script = ''
export BOOK_TELEGRAM_BOT_TOKEN="$(< "${cfg.telegramBotTokenFile}")"
exec ${cfg.package}/bin/guestbook
'';
};
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
};
users.groups.${cfg.group} = {};
}
(mkIf cfg.caddy.enable {
services.caddy.virtualHosts.${cfg.caddy.domain}.extraConfig = ''
reverse_proxy localhost:${toString cfg.port}
encode zstd gzip
'';
})
]);
}

View file

@ -1,9 +1,9 @@
use serde::Deserialize; use std::env;
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Debug, Deserialize)] #[derive(Debug)]
pub struct Config { pub struct Config {
pub listen: String, pub port: u16,
pub data_dir: PathBuf, pub data_dir: PathBuf,
pub site_title: String, pub site_title: String,
pub site_url: String, pub site_url: String,
@ -12,10 +12,28 @@ pub struct Config {
} }
impl Config { impl Config {
pub fn load(path: &str) -> Result<Self, Box<dyn std::error::Error>> { pub fn listen_addr(&self) -> String {
let content = std::fs::read_to_string(path)?; format!("127.0.0.1:{}", self.port)
let config: Config = toml::from_str(&content)?; }
Ok(config)
pub fn from_env() -> Result<Self, String> {
Ok(Config {
port: env::var("BOOK_PORT")
.unwrap_or_else(|_| "8123".into())
.parse()
.map_err(|_| "BOOK_PORT must be a number")?,
data_dir: env::var("BOOK_DATA_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("./data")),
site_title: env::var("BOOK_SITE_TITLE").unwrap_or_else(|_| "guestbook".into()),
site_url: env::var("BOOK_SITE_URL").map_err(|_| "BOOK_SITE_URL is required")?,
telegram_bot_token: env::var("BOOK_TELEGRAM_BOT_TOKEN")
.map_err(|_| "BOOK_TELEGRAM_BOT_TOKEN is required")?,
telegram_chat_id: env::var("BOOK_TELEGRAM_CHAT_ID")
.map_err(|_| "BOOK_TELEGRAM_CHAT_ID is required")?
.parse()
.map_err(|_| "BOOK_TELEGRAM_CHAT_ID must be an integer")?,
})
} }
} }
@ -24,20 +42,54 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_parse_config() { fn test_from_env() {
let toml_str = r#" env::set_var("BOOK_PORT", "9999");
listen = "127.0.0.1:8123" env::set_var("BOOK_DATA_DIR", "/tmp/gb");
data_dir = "/var/lib/guestbook" env::set_var("BOOK_SITE_TITLE", "test.rs");
site_title = "ily.rs" env::set_var("BOOK_SITE_URL", "https://test.rs");
site_url = "https://ily.rs" env::set_var("BOOK_TELEGRAM_BOT_TOKEN", "123:ABC");
telegram_bot_token = "123:ABC" env::set_var("BOOK_TELEGRAM_CHAT_ID", "12345");
telegram_chat_id = 12345
"#; let config = Config::from_env().unwrap();
let config: Config = toml::from_str(toml_str).unwrap(); assert_eq!(config.port, 9999);
assert_eq!(config.listen, "127.0.0.1:8123"); assert_eq!(config.listen_addr(), "127.0.0.1:9999");
assert_eq!(config.data_dir, PathBuf::from("/var/lib/guestbook")); assert_eq!(config.data_dir, PathBuf::from("/tmp/gb"));
assert_eq!(config.site_title, "ily.rs"); assert_eq!(config.site_title, "test.rs");
assert_eq!(config.site_url, "https://ily.rs"); assert_eq!(config.site_url, "https://test.rs");
assert_eq!(config.telegram_chat_id, 12345); assert_eq!(config.telegram_chat_id, 12345);
// Clean up
env::remove_var("BOOK_PORT");
env::remove_var("BOOK_DATA_DIR");
env::remove_var("BOOK_SITE_TITLE");
env::remove_var("BOOK_SITE_URL");
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
}
#[test]
fn test_defaults() {
env::set_var("BOOK_SITE_URL", "https://test.rs");
env::set_var("BOOK_TELEGRAM_BOT_TOKEN", "123:ABC");
env::set_var("BOOK_TELEGRAM_CHAT_ID", "12345");
let config = Config::from_env().unwrap();
assert_eq!(config.port, 8123);
assert_eq!(config.data_dir, PathBuf::from("./data"));
assert_eq!(config.site_title, "guestbook");
env::remove_var("BOOK_SITE_URL");
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
}
#[test]
fn test_missing_required() {
env::remove_var("BOOK_SITE_URL");
env::remove_var("BOOK_TELEGRAM_BOT_TOKEN");
env::remove_var("BOOK_TELEGRAM_CHAT_ID");
let result = Config::from_env();
assert!(result.is_err());
} }
} }

View file

@ -11,8 +11,9 @@ use teloxide::prelude::*;
async fn main() { async fn main() {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
let config = config::Config::load("config.toml").expect("failed to load config.toml"); dotenvy::dotenv().ok();
let listen = config.listen.clone(); let config = config::Config::from_env().expect("failed to load config");
let listen = config.listen_addr();
let entries_dir = config.data_dir.join("entries"); let entries_dir = config.data_dir.join("entries");
let chat_id = ChatId(config.telegram_chat_id); let chat_id = ChatId(config.telegram_chat_id);

View file

@ -8,7 +8,7 @@ pub fn render_page(site_title: &str, site_url: &str, entries: &[Entry], form_htm
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>guestbook - {site_title}</title> <title>{site_title}</title>
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
</head> </head>
<body> <body>