Implement basic functionality

I've been working on this off-and-on for a few days without committing
anything 🤨. With this commit, the basic functionality for writing
configuration files from _etcd_ keys is in place.  The daemon connects
to _etcd_ on startup, reads all currently-populated keys, and then
enters a watch loop.  It gracefully handles being disconnected from
_etcd_ and will reconnect with a backoff timer.  In addition to writing
file contents, the daemon can set file permissions and run commands
after changing files.
main
Dustin 2024-07-28 10:12:11 -05:00
parent f1160846db
commit da756ff1a4
10 changed files with 1253 additions and 79 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
/target
config.toml

487
Cargo.lock generated
View File

@ -51,7 +51,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.72",
]
[[package]]
@ -62,7 +62,7 @@ checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.72",
]
[[package]]
@ -151,6 +151,24 @@ version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bytes"
version = "1.6.1"
@ -169,6 +187,63 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cpufeatures"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "derive-adhoc"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5283ac2881753c76c0892406705553f0d9ab30649f81e18964d3408f4501edb8"
dependencies = [
"derive-adhoc-macros",
"heck 0.4.1",
]
[[package]]
name = "derive-adhoc-macros"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c21b673a9b8c78c34908e6fcb42b922e11c4df2de5237f1c3f58d3285904a84b"
dependencies = [
"heck 0.4.1",
"itertools 0.11.0",
"proc-macro-crate",
"proc-macro2",
"quote",
"sha3",
"strum",
"syn 1.0.109",
"void",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "either"
version = "1.13.0"
@ -213,6 +288,15 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a"
[[package]]
name = "file-mode"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773ea145485772b8d354624b32adbe20e776353d3e48c7b03ef44e3455e9815c"
dependencies = [
"libc",
]
[[package]]
name = "fixedbitset"
version = "0.4.2"
@ -225,6 +309,15 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "form_urlencoded"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.30"
@ -264,6 +357,16 @@ dependencies = [
"pin-utils",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.15"
@ -312,6 +415,12 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
@ -324,6 +433,23 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hostname"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba"
dependencies = [
"cfg-if",
"libc",
"windows",
]
[[package]]
name = "http"
version = "1.1.0"
@ -424,6 +550,16 @@ dependencies = [
"tracing",
]
[[package]]
name = "idna"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
dependencies = [
"unicode-bidi",
"unicode-normalization",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@ -444,6 +580,15 @@ dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "itertools"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
@ -459,6 +604,15 @@ version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
name = "keccak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654"
dependencies = [
"cpufeatures",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@ -471,6 +625,12 @@ version = "0.2.155"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
[[package]]
name = "libyml"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64804cc6a5042d4f05379909ba25b503ec04e2c082151d62122d5dcaa274b961"
[[package]]
name = "linux-raw-sys"
version = "0.4.14"
@ -487,10 +647,23 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
name = "luci"
version = "0.1.0"
dependencies = [
"base64",
"blake2",
"etcd-client",
"file-mode",
"hex",
"hostname",
"pwd-grp",
"sd-notify",
"serde",
"serde_yml",
"shlex",
"thiserror",
"tokio",
"toml",
"tracing",
"tracing-subscriber",
"url",
]
[[package]]
@ -578,6 +751,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "percent-encoding"
version = "2.3.1"
@ -611,7 +790,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.72",
]
[[package]]
@ -639,7 +818,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e"
dependencies = [
"proc-macro2",
"syn",
"syn 2.0.72",
]
[[package]]
name = "proc-macro-crate"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
dependencies = [
"once_cell",
"toml_edit 0.19.15",
]
[[package]]
@ -668,8 +857,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bb182580f71dd070f88d01ce3de9f4da5021db7115d2e1c3605a754153b77c1"
dependencies = [
"bytes",
"heck",
"itertools",
"heck 0.5.0",
"itertools 0.13.0",
"log",
"multimap",
"once_cell",
@ -678,7 +867,7 @@ dependencies = [
"prost",
"prost-types",
"regex",
"syn",
"syn 2.0.72",
"tempfile",
]
@ -689,10 +878,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18bec9b0adc4eba778b33684b7ba3e7137789434769ee3ce3930463ef904cfca"
dependencies = [
"anyhow",
"itertools",
"itertools 0.13.0",
"proc-macro2",
"quote",
"syn",
"syn 2.0.72",
]
[[package]]
@ -704,6 +893,18 @@ dependencies = [
"prost",
]
[[package]]
name = "pwd-grp"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6955c41fd7e4283bdf6ff3e7218b7e3f8ef24c4236b31d22be050f4cfd5e2a2c"
dependencies = [
"derive-adhoc",
"libc",
"paste",
"thiserror",
]
[[package]]
name = "quote"
version = "1.0.36"
@ -869,6 +1070,18 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6"
[[package]]
name = "ryu"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "sd-notify"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4646d6f919800cd25c50edb49438a1381e2cd4833c027e75e8897981c50b8b5e"
[[package]]
name = "serde"
version = "1.0.204"
@ -886,7 +1099,54 @@ checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.72",
]
[[package]]
name = "serde_json"
version = "1.0.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_spanned"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0"
dependencies = [
"serde",
]
[[package]]
name = "serde_yml"
version = "0.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e76bab63c3fd98d27c17f9cbce177f64a91f5e69ac04cafe04e1bb25d1dc3c"
dependencies = [
"indexmap 2.2.6",
"itoa",
"libyml",
"log",
"memchr",
"ryu",
"serde",
"serde_json",
"tempfile",
]
[[package]]
name = "sha3"
version = "0.10.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
dependencies = [
"digest",
"keccak",
]
[[package]]
@ -898,6 +1158,12 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.2"
@ -938,12 +1204,45 @@ version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "strum"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0"
dependencies = [
"heck 0.4.1",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.72",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "2.0.72"
@ -979,6 +1278,26 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "thiserror"
version = "1.0.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.72",
]
[[package]]
name = "thread_local"
version = "1.1.8"
@ -989,6 +1308,21 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tinyvec"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.39.1"
@ -1014,7 +1348,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.72",
]
[[package]]
@ -1052,6 +1386,51 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac2caab0bf757388c6c0ae23b3293fdb463fee59434529014f85e3263b995c28"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit 0.22.16",
]
[[package]]
name = "toml_datetime"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
"indexmap 2.2.6",
"toml_datetime",
"winnow 0.5.40",
]
[[package]]
name = "toml_edit"
version = "0.22.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "278f3d518e152219c994ce877758516bca5e118eaed6996192a774fb9fbf0788"
dependencies = [
"indexmap 2.2.6",
"serde",
"serde_spanned",
"toml_datetime",
"winnow 0.6.15",
]
[[package]]
name = "tonic"
version = "0.12.1"
@ -1094,7 +1473,7 @@ dependencies = [
"proc-macro2",
"prost-build",
"quote",
"syn",
"syn 2.0.72",
]
[[package]]
@ -1148,7 +1527,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.72",
]
[[package]]
@ -1196,24 +1575,69 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
name = "unicode-bidi"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75"
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "unicode-normalization"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5"
dependencies = [
"tinyvec",
]
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "valuable"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
[[package]]
name = "version_check"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "void"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
[[package]]
name = "want"
version = "0.3.1"
@ -1251,6 +1675,25 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
dependencies = [
"windows-core",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@ -1324,6 +1767,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "557404e450152cd6795bb558bca69e43c585055f4606e3bcae5894fc6dac9ba0"
dependencies = [
"memchr",
]
[[package]]
name = "zeroize"
version = "1.8.1"

View File

@ -4,7 +4,20 @@ version = "0.1.0"
edition = "2021"
[dependencies]
base64 = "0.22.1"
blake2 = "0.10.6"
etcd-client = { version = "0.14.0", features = ["tls"] }
tokio = { version = "1.39.1", default-features = false, features = ["rt", "macros", "rt-multi-thread", "signal"] }
file-mode = "0.1.2"
hex = "0.4.3"
hostname = "0.4.0"
pwd-grp = "0.1.1"
sd-notify = "0.4.2"
serde = { version = "1.0.204", features = ["derive"] }
serde_yml = "0.0.11"
shlex = "1.3.0"
thiserror = "1.0.63"
tokio = { version = "1.39.1", default-features = false, features = ["rt", "macros", "rt-multi-thread", "signal", "process"] }
toml = "0.8.15"
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
url = { version = "2.5.2", features = ["serde"] }

1
rustfmt.toml Normal file
View File

@ -0,0 +1 @@
max_width = 79

48
src/backoff.rs Normal file
View File

@ -0,0 +1,48 @@
use std::iter::Iterator;
use std::time::Duration;
use tracing::warn;
pub struct Backoff {
duration: Duration,
max: Duration,
min: Duration,
}
impl Default for Backoff {
fn default() -> Self {
let min = Duration::from_millis(250);
let max = Duration::from_secs(300);
let duration = min;
Self { duration, max, min }
}
}
impl Iterator for Backoff {
type Item = Duration;
fn next(&mut self) -> Option<Self::Item> {
let duration = self.duration;
self.duration = std::cmp::min(self.duration * 2, self.max);
Some(duration)
}
}
impl Backoff {
#[allow(dead_code)]
pub fn reset(&mut self) {
self.duration = self.min;
}
pub async fn sleep(&mut self) {
warn!(
"Retrying in {}",
if self.duration.as_secs() < 1 {
format!("{} ms", self.duration.as_millis())
} else {
format!("{} seconds", self.duration.as_secs())
}
);
tokio::time::sleep(self.next().unwrap()).await;
}
}

112
src/config.rs Normal file
View File

@ -0,0 +1,112 @@
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use tracing::{debug, info};
use url::Url;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Unable to read configuration file: {0}")]
Io(#[from] std::io::Error),
#[error("Failed to parse configuration file: {0}")]
Toml(#[from] toml::de::Error),
}
#[derive(Debug, Deserialize)]
pub struct EtcdConfig {
#[serde(default = "default_etcd_hosts")]
pub hosts: Vec<Url>,
pub client_cert: Option<PathBuf>,
pub client_key: Option<PathBuf>,
pub ca_cert: Option<PathBuf>,
}
#[derive(Debug, Deserialize)]
pub struct Configuration {
#[serde(default = "default_namespace")]
pub namespace: String,
#[serde(default)]
pub path_prefix: PathBuf,
pub etcd: EtcdConfig,
}
impl Default for EtcdConfig {
fn default() -> Self {
Self {
hosts: default_etcd_hosts(),
client_cert: None,
client_key: None,
ca_cert: None,
}
}
}
impl Default for Configuration {
fn default() -> Self {
Self {
namespace: default_namespace(),
path_prefix: Default::default(),
etcd: Default::default(),
}
}
}
impl Configuration {
pub fn load(path: Option<impl AsRef<Path>>) -> Result<Self, Error> {
let path = path
.map(|p| p.as_ref().into())
.unwrap_or_else(Self::default_path);
debug!("Loading configuration from {}", path.display());
let config = match std::fs::read_to_string(&path) {
Ok(d) => toml::from_str(&d)?,
Err(e) if e.kind() == ErrorKind::NotFound => {
debug!("{}: {}", path.display(), e);
Default::default()
}
Err(e) => return Err(e.into()),
};
match std::env::var("DUMP_CONFIG") {
Ok(s) if s == "1" => info!("{:?}", config),
Ok(_) | Err(_) => (),
};
Ok(config)
}
pub fn default_path() -> PathBuf {
match std::env::var_os(format!(
"{}_CONFIG",
env!("CARGO_PKG_NAME").to_uppercase()
)) {
Some(v) => PathBuf::from(v),
None => match std::env::var_os("XDG_CONFIG_HOME") {
Some(v) => {
let mut p = PathBuf::from(v);
p.push(env!("CARGO_PKG_NAME"));
p.push("config.toml");
p
}
None => match std::env::var_os("HOME") {
Some(v) => {
let mut p = PathBuf::from(v);
p.push(".config");
p.push(env!("CARGO_PKG_NAME"));
p.push("config.toml");
p
}
None => PathBuf::from("config.toml"),
},
},
}
}
}
fn default_namespace() -> String {
env!("CARGO_PKG_NAME").into()
}
fn default_etcd_hosts() -> Vec<Url> {
vec![Url::parse("http://localhost:2379").unwrap()]
}

193
src/luci.rs Normal file
View File

@ -0,0 +1,193 @@
use etcd_client::{
Certificate, Client, ConnectOptions, GetOptions, Identity, KeyValue,
TlsOptions, WatchOptions,
};
use sd_notify::NotifyState;
use tokio::task::JoinHandle;
use tracing::{debug, error, info, trace, warn};
use crate::backoff::Backoff;
use crate::config::Configuration;
use crate::model::ContentError;
use crate::processor::{Processor, Sender};
#[derive(Debug, thiserror::Error)]
pub(crate) enum WatchError {
#[error("Could not get system hostname: {0}")]
Hostname(std::io::Error),
#[error("Error communicating with etcd server: {0}")]
Etcd(#[from] etcd_client::Error),
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum ConnectError {
#[error("Invalid etcd client configuration: {0}")]
InvalidConfig(etcd_client::Error),
#[error("Failed to load client certificate/key: {0}")]
Certificate(std::io::Error),
#[error("Failed to load CA certificate: {0}")]
CaCert(std::io::Error),
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum HandleError {
#[error("Error communicating with etcd: {0}")]
Etcd(#[from] etcd_client::Error),
#[error("Invalid UTF-8 etcd key")]
Utf8(#[from] std::str::Utf8Error),
#[error("Could not parse YAML: {0}")]
Yaml(#[from] serde_yml::Error),
#[error("Error decoding content: {0}")]
Content(#[from] ContentError),
}
pub(crate) struct Luci {
client: Option<Client>,
config: Configuration,
tx: Sender,
processor: JoinHandle<()>,
}
impl Luci {
pub fn new(config: Configuration) -> Self {
let path_prefix = config.path_prefix.clone();
let (tx, processor) = Processor::new(path_prefix).start();
Self {
client: None,
config,
tx,
processor,
}
}
pub async fn connect(&mut self) -> Result<&Client, ConnectError> {
if let Some(client) = self.client.as_mut() {
if let Err(e) = client.status().await {
error!("Lost connection to etcd server: {}", e);
self.client.take();
} else {
return Ok(self.client.as_ref().unwrap());
}
}
let addrs = &self.config.etcd.hosts;
let mut backoff = Backoff::default();
let options = self.connect_options()?;
loop {
let mut client = Client::connect(addrs, options.clone())
.await
.map_err(ConnectError::InvalidConfig)?;
match client.status().await {
Ok(s) => {
info!("Connected to etcd server version {}", s.version())
}
Err(e) => {
error!("Failed to connect to etcd: {:?}", e);
backoff.sleep().await;
continue;
}
}
self.client = Some(client);
return Ok(self.client.as_ref().unwrap());
}
}
pub async fn stop(self) {
debug!("Notifying processor to stop");
drop(self.tx);
if let Err(e) = self.processor.await {
error!("Error in processor task: {}", e);
}
debug!("Processor stopped");
}
pub async fn watch(&mut self) -> Result<(), WatchError> {
let key = format!(
"{}/{}/config",
self.config.namespace,
hostname::get()
.map_err(WatchError::Hostname)?
.to_string_lossy()
);
let get_options = GetOptions::new().with_prefix();
let watch_options = WatchOptions::new().with_prefix();
if let Some(client) = self.client.as_mut() {
let mut watch_client = client.watch_client();
debug!("Fetching initial data from key: {}", key);
notify_init();
let response = client.get(&key[..], Some(get_options)).await?;
for kv in response.kvs() {
if let Err(e) = self.handle_kv(kv).await {
error!("Error handling response: {}", e);
}
}
debug!("Setting up watch for key: {}", key);
let (_, mut stream) =
watch_client.watch(&key[..], Some(watch_options)).await?;
info!("Watching keys under '{}'", key);
notify_ready();
while let Some(message) = stream.message().await? {
trace!("Received message: {:?}", message);
for event in message.events() {
if let Some(kv) = event.kv() {
if let Err(e) = self.handle_kv(kv).await {
error!("Error handling message: {}", e);
}
}
}
}
error!("Lost connection to watch stream");
}
Ok(())
}
fn connect_options(&self) -> Result<Option<ConnectOptions>, ConnectError> {
if self.config.etcd.client_cert.is_none()
&& self.config.etcd.ca_cert.is_none()
{
return Ok(None);
}
let mut tls_options = TlsOptions::new();
if let Some(c) = self.config.etcd.client_cert.as_ref() {
let key = if let Some(k) = self.config.etcd.client_key.as_ref() {
std::fs::read(k).map_err(ConnectError::Certificate)?
} else {
vec![]
};
let cert = std::fs::read(c).map_err(ConnectError::Certificate)?;
tls_options = tls_options.identity(Identity::from_pem(cert, key));
}
if let Some(c) = self.config.etcd.ca_cert.as_ref() {
tls_options = tls_options.ca_certificate(Certificate::from_pem(
std::fs::read(c).map_err(ConnectError::CaCert)?,
));
}
Ok(Some(ConnectOptions::new().with_tls(tls_options)))
}
async fn handle_kv(&mut self, kv: &KeyValue) -> Result<(), HandleError> {
let key = String::from_utf8_lossy(kv.key());
debug!("Handling notification for key {}", key);
let value = serde_yml::from_slice(kv.value())?;
if self.tx.send((key.into(), value)).await.is_err() {
error!("Failed to send entry to processor");
}
Ok(())
}
}
fn notify_ready() {
let state = &[
NotifyState::Status("Watching for etcd key changes"),
NotifyState::Ready,
];
if let Err(e) = sd_notify::notify(true, state) {
warn!("Failed to send ready notification to service manager: {}", e);
}
}
fn notify_init() {
let state = &[NotifyState::Status("Fetching initial configuration")];
if let Err(e) = sd_notify::notify(false, state) {
warn!("Failed to send status notification to service manager: {}", e);
}
}

View File

@ -1,89 +1,74 @@
mod backoff;
mod config;
mod luci;
mod model;
mod processor;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use etcd_client::Client;
use tokio::sync::Notify;
use tokio::signal::unix::SignalKind;
use tracing::{debug, info, error, trace};
use tokio::sync::Notify;
use tracing::{debug, error, info};
use config::Configuration;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.without_time()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
let stop = Arc::new(Notify::new());
let my_stop = stop.clone();
let task = tokio::spawn(async move {
run(&stop).await
});
if let Err(e) = wait_signal().await {
error!("Failed to set up signal handler: {}", e);
std::process::exit(1);
let mut args = std::env::args_os();
let config_path = args.nth(1).map(PathBuf::from);
let config = match Configuration::load(config_path.as_deref()) {
Ok(c) => c,
Err(e) => {
error!("Failed to load configuration: {}", e);
std::process::exit(1);
}
};
my_stop.notify_waiters();
if let Err(e) = task.await {
error!("{}", e);
std::process::exit(1);
}
let stop = Arc::new(Notify::new());
let stopper = stop.clone();
tokio::spawn(async move {
if let Err(e) = wait_signal().await {
error!("Failed to set up signal handler: {}", e);
}
stopper.notify_waiters();
});
run(config, &stop).await;
}
async fn run(stop: &Notify) {
async fn run(config: Configuration, stop: &Notify) {
let mut luci = luci::Luci::new(config);
loop {
let mut client = tokio::select! {
c = connect() => c,
_ = stop.notified() => return
};
let (_, mut stream) = match client.watch("dustin", None).await {
Ok((w, s)) => (w, s),
Err(e) => {
error!("Error setting up watch: {}", e);
continue;
}
};
info!("Watch stream established");
tokio::select! {
m = stream.message() => {
trace!("{:?}", m);
}
r = luci.connect() => if let Err(e) = r {
error!("Unable to connect to etcd server: {}", e);
break;
},
_ = stop.notified() => break,
};
tokio::select! {
r = luci.watch() => if let Err(e) = r {
error!("Error in watch stream: {}", e);
},
_ = stop.notified() => break,
};
if tokio::time::timeout(Duration::from_secs(1), stop.notified())
.await
.is_ok()
{
break;
}
}
info!("Shutting down");
}
async fn connect() -> Client {
let mut duration = Duration::from_millis(250);
let addrs = [
"http://127.0.0.1:2379",
];
loop {
let mut client = match Client::connect(&addrs, None).await {
Ok(c) => c,
Err(e) => {
error!("Failed to connect to etcd: {}", e);
tokio::time::sleep(duration).await;
if duration.as_secs() < 300 {
duration *= 2;
}
continue;
}
};
// Client::connect() always succeeds?
match client.status().await {
Ok(s) => info!("Connected: {:?}", s),
Err(e) => {
error!("Failed to connect to etcd: {}", e);
tokio::time::sleep(duration).await;
if duration.as_secs() < 300 {
duration *= 2;
}
continue;
}
}
return client;
}
luci.stop().await;
}
async fn wait_signal() -> std::io::Result<()> {

74
src/model.rs Normal file
View File

@ -0,0 +1,74 @@
use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, thiserror::Error)]
pub enum ContentError {
#[error("Base64 decoding error: {0}")]
Base64(#[from] base64::DecodeError),
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
enum ContentEncoding {
Raw,
Base64,
}
#[derive(Debug, Default, Deserialize)]
pub struct Hooks {
#[serde(default)]
pub changed: Vec<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct ConfigEntry {
path: PathBuf,
owner: Option<String>,
group: Option<String>,
mode: Option<String>,
#[serde(default)]
content_encoding: ContentEncoding,
content: String,
#[serde(default)]
hooks: Hooks,
}
impl Default for ContentEncoding {
fn default() -> Self {
Self::Raw
}
}
impl ConfigEntry {
pub fn path(&self) -> &Path {
self.path.as_ref()
}
pub fn owner(&self) -> Option<&str> {
self.owner.as_deref()
}
pub fn group(&self) -> Option<&str> {
self.group.as_deref()
}
pub fn mode(&self) -> Option<&str> {
self.mode.as_deref()
}
pub fn content(&self) -> Result<Vec<u8>, ContentError> {
match &self.content_encoding {
ContentEncoding::Raw => Ok(self.content.as_bytes().into()),
ContentEncoding::Base64 => Ok(base64_decode(&self.content)?),
}
}
pub fn hooks(&self) -> &Hooks {
&self.hooks
}
}
fn base64_decode(value: &str) -> Result<Vec<u8>, base64::DecodeError> {
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, value)
}

286
src/processor.rs Normal file
View File

@ -0,0 +1,286 @@
use std::io::{Read, Seek, Write};
use std::os::unix::fs::MetadataExt;
#[cfg(unix)]
use std::os::unix::process::ExitStatusExt;
use std::path::{Path, PathBuf};
use blake2::{Blake2b512, Digest};
use file_mode::{Mode, ModePath};
use shlex::Shlex;
use tokio::process::Command;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tracing::Instrument;
use tracing::{debug, error, info, trace, warn};
use crate::model::{ConfigEntry, ContentError};
pub type Message = (String, ConfigEntry);
pub type Sender = mpsc::Sender<Message>;
pub type Receiver = mpsc::Receiver<Message>;
#[derive(Debug, thiserror::Error)]
pub(crate) enum ProcessError {
#[error("Content error: {0}")]
Content(#[from] ContentError),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum SetPermissionsError {
#[error("{0}")]
Io(#[from] std::io::Error),
#[error("User not found: {0}")]
UserNotFound(String),
#[error("Group not found: {0}")]
GroupNotFound(String),
#[error("Bad mode string: {0}")]
BadMode(#[from] file_mode::ModeParseError),
}
pub struct Processor {
path_prefix: PathBuf,
}
impl Processor {
pub fn new(path_prefix: PathBuf) -> Self {
Self { path_prefix }
}
pub fn start(self) -> (Sender, JoinHandle<()>) {
let (tx, rx) = mpsc::channel(512);
let handle = tokio::spawn(async move { self.run(rx).await });
(tx, handle)
}
async fn process(&self, entry: ConfigEntry) -> Result<(), ProcessError> {
let path = self
.path_prefix
.join(entry.path().strip_prefix("/").unwrap_or(entry.path()));
let changed = write_file(&path, &entry.content()?)?;
#[cfg(unix)]
{
if entry.owner().is_some() || entry.group().is_some() {
if let Err(e) = chown(&path, entry.owner(), entry.group()) {
error!(
"Failed to set ownership of {}: {}",
path.display(),
e
);
}
}
if let Some(mode) = entry.mode() {
if let Err(e) = chmod(&path, mode) {
error!("Failed to set mode of {}: {}", path.display(), e);
}
}
}
if changed {
info!("File {} was changed", path.display());
let hooks = entry.hooks();
debug!("Processing hooks: {:?}", hooks);
for hook in hooks.changed.iter() {
if let Some(args) = parse_hook(hook, &path) {
run_hook(args).await;
}
}
} else {
debug!("File {} was NOT changed", path.display());
}
Ok(())
}
async fn run(self, mut rx: Receiver) {
while let Some((key, entry)) = rx.recv().await {
let span = tracing::debug_span!("process", key = key);
if let Err(e) = self.process(entry).instrument(span).await {
error!("Error while processing config entry: {}", e)
}
}
debug!("Channel closed");
}
}
fn checksum(path: impl AsRef<Path>) -> std::io::Result<Vec<u8>> {
let mut f = std::fs::File::open(path)?;
let mut blake = Blake2b512::new();
loop {
let mut buf = vec![0u8; 16384];
let sz = f.read(&mut buf)?;
if sz == 0 {
break;
}
blake.update(&buf[..sz]);
}
Ok(blake.finalize().to_vec())
}
fn write_file(
dest: impl AsRef<Path>,
data: &[u8],
) -> Result<bool, std::io::Error> {
if let Some(p) = dest.as_ref().parent() {
if !p.exists() {
info!("Creating directory {}", p.display());
std::fs::create_dir_all(p)?;
}
}
if let Ok(orig_cksm) = checksum(&dest) {
trace!(
"Original checksum: {}: {}",
dest.as_ref().display(),
hex::encode(&orig_cksm)
);
let mut blake = Blake2b512::new();
blake.update(data);
let new_cksm = blake.finalize().to_vec();
trace!(
"New checksum: {}: {}",
dest.as_ref().display(),
hex::encode(&new_cksm)
);
if orig_cksm == new_cksm {
return Ok(false);
}
}
debug!("Writing output: {}", dest.as_ref().display());
let mut f = std::fs::File::create(&dest)?;
f.write_all(data)?;
let size = f.stream_position()?;
debug!("Wrote output: {} ({} bytes)", dest.as_ref().display(), size);
Ok(true)
}
#[cfg(unix)]
fn chown(
path: impl AsRef<Path>,
owner: Option<&str>,
group: Option<&str>,
) -> Result<(), SetPermissionsError> {
let uid = if let Some(owner) = owner {
debug!("Looking up UID for user {}", owner);
if let Some(pw) = pwd_grp::getpwnam(owner)? {
debug!("Found UID {} for user {}", pw.uid, owner);
Some(pw.uid)
} else {
return Err(SetPermissionsError::UserNotFound(owner.into()));
}
} else {
None
};
let gid = if let Some(group) = group {
debug!("Looking up GID for group {}", group);
if let Some(gr) = pwd_grp::getgrnam(group)? {
debug!("Found GID {} for group {}", gr.gid, group);
Some(gr.gid)
} else {
return Err(SetPermissionsError::GroupNotFound(group.into()));
}
} else {
None
};
let metadata = std::fs::metadata(&path)?;
if Some(metadata.uid()) != uid || Some(metadata.gid()) != gid {
debug!(
"Setting ownership of {} to {:?} / {:?}",
path.as_ref().display(),
uid,
gid
);
std::os::unix::fs::chown(path, uid, gid)?
}
Ok(())
}
#[cfg(unix)]
fn chmod(
path: impl AsRef<Path>,
mode: &str,
) -> Result<(), SetPermissionsError> {
let current_mode = path.as_ref().mode()?;
let mut filemode = current_mode.clone();
match u32::from_str_radix(mode, 8) {
Ok(m) => filemode.set(&Mode::from(m)),
Err(_) => filemode.set_str_umask(mode, 0o777)?,
}
if current_mode != filemode {
debug!(
"Changing mode of {} from {} to {}",
path.as_ref().display(),
current_mode,
filemode,
);
let newmode = file_mode::Mode::from(filemode.set_mode_path(&path)?);
info!("Set mode of {} to {}", path.as_ref().display(), newmode);
}
Ok(())
}
fn parse_hook(command: &str, path: &Path) -> Option<Vec<String>> {
let mut bad_path = false;
let args: Vec<_> = Shlex::new(command)
.map(|a| {
if a == "%s" {
if let Some(p) = path.as_os_str().to_str() {
p.into()
} else {
bad_path = true;
a
}
} else {
a
}
})
.collect();
if bad_path {
warn!("Cannot run hook: path is not valid UTF-8");
return None;
}
if args.is_empty() {
warn!(
"Invalid hook for {} ({}): empty argument list",
path.display(),
command
);
return None;
}
Some(args)
}
async fn run_hook(args: Vec<String>) {
match shlex::try_join(args.iter().map(String::as_str)) {
Ok(cmd) => info!("Running hook: {}", cmd),
Err(e) => {
error!("Invalid hook: {}", e);
return;
}
}
if let Err(e) = _run_hook(args).await {
error!("Error running hook: {}", e);
}
}
async fn _run_hook(args: Vec<String>) -> std::io::Result<()> {
let status = Command::new(&args[0])
.args(&args[1..])
.stdin(std::process::Stdio::null())
.spawn()?
.wait()
.await?;
match status.code() {
Some(0) => info!("Hook completed successfully"),
Some(c) => error!("Hook exited with status {}", c),
#[cfg(unix)]
None => {
if let Some(signal) = status.signal() {
error!("Hook killed by signal {}", signal);
} else {
error!("Hook exited with unknown status");
}
}
#[cfg(not(unix))]
None => error!("Hook exited with unknown status"),
}
Ok(())
}