From 3edea4bdfff0c89a645e63e47bb58369ba015468 Mon Sep 17 00:00:00 2001 From: Sunil Thakares Date: Wed, 13 May 2026 15:54:12 +0530 Subject: [PATCH] Implement CLI configuration and runtime management --- Cargo.lock | 430 ++++++++++++++++++++++++++++++++++- Dockerfile | 10 +- chronoseal.service | 44 +++- docs/chronoseal.example.toml | 5 + scripts/build.sh | 20 +- scripts/install.sh | 52 +++++ scripts/release.sh | 13 +- server/Cargo.toml | 11 +- server/src/cli.rs | 132 +++++++++++ server/src/config.rs | 159 +++++++++++++ server/src/main.rs | 154 ++++++++++--- server/src/output.rs | 18 ++ server/src/runtime.rs | 323 ++++++++++++++++++++++++++ server/src/storage.rs | 44 +++- 14 files changed, 1362 insertions(+), 53 deletions(-) create mode 100644 docs/chronoseal.example.toml mode change 100644 => 100755 scripts/build.sh create mode 100755 scripts/install.sh mode change 100644 => 100755 scripts/release.sh create mode 100644 server/src/cli.rs create mode 100644 server/src/config.rs create mode 100644 server/src/output.rs create mode 100644 server/src/runtime.rs diff --git a/Cargo.lock b/Cargo.lock index 9609ad1..d293261 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,65 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -173,17 +232,22 @@ version = "0.2.0" dependencies = [ "axum", "base64", + "clap", + "clap_complete", "ed25519-dalek", "hex", "rand", "rusqlite", "serde", "serde_json", + "serde_yaml", "shared", "tokio", + "toml", "tower 0.4.13", "tower-http", "tracing", + "tracing-appender", "tracing-subscriber", ] @@ -204,6 +268,62 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_complete" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "const-oid" version = "0.9.6" @@ -234,6 +354,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -281,6 +416,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -316,6 +460,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -430,15 +580,27 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -531,6 +693,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -572,6 +750,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -587,6 +771,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.7.3" @@ -635,12 +828,24 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -692,6 +897,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -758,6 +969,23 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rusqlite" version = "0.31.0" @@ -781,6 +1009,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -870,6 +1111,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -882,6 +1132,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha2" version = "0.10.9" @@ -972,12 +1235,24 @@ dependencies = [ "der", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "2.0.117" @@ -995,6 +1270,36 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -1004,6 +1309,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tokio" version = "1.52.2" @@ -1045,6 +1381,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.4.13" @@ -1121,6 +1498,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -1153,18 +1543,35 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -1185,6 +1592,18 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "valuable" version = "0.1.1" @@ -1269,6 +1688,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "zerocopy" version = "0.8.48" diff --git a/Dockerfile b/Dockerfile index 4ab899f..94d49bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app COPY . . -RUN cargo build -p server --release +RUN cargo build -p chronoseal-server --bin chronoseal --release FROM debian:bookworm-slim @@ -14,10 +14,14 @@ RUN apt-get update && apt-get install -y \ WORKDIR /opt/chronoseal -COPY --from=builder /app/target/release/server /usr/local/bin/chronoseal +COPY --from=builder /app/target/release/chronoseal /usr/local/bin/chronoseal +COPY frontend /usr/share/chronoseal/frontend EXPOSE 3000 ENV RUST_LOG=info +ENV CHRONOSEAL_DB_PATH=/var/lib/chronoseal/chronoseal.sqlite +ENV CHRONOSEAL_FRONTEND_DIR=/usr/share/chronoseal/frontend +ENV CHRONOSEAL_PID_FILE=/run/chronoseal.pid -CMD ["chronoseal"] +CMD ["chronoseal", "run"] diff --git a/chronoseal.service b/chronoseal.service index ad3f850..f0bd67a 100644 --- a/chronoseal.service +++ b/chronoseal.service @@ -1,6 +1,8 @@ [Unit] -Description=ChronoSeal Anti-Bot Service -After=network.target +Description=ChronoSeal cryptographic browser attestation service +Documentation=https://chronoseal.rs +After=network-online.target +Wants=network-online.target [Service] Type=simple @@ -8,28 +10,58 @@ Type=simple User=chronoseal Group=chronoseal -WorkingDirectory=/opt/chronoseal +Environment=RUST_LOG=info +Environment=CHRONOSEAL_CONFIG=/etc/chronoseal/config.toml +Environment=CHRONOSEAL_STATE_DIR=/var/lib/chronoseal +Environment=CHRONOSEAL_PID_FILE=/run/chronoseal.pid -ExecStart=/usr/local/bin/chronoseal +ExecStart=/usr/local/bin/chronoseal run +ExecStartPre=+/usr/bin/touch /run/chronoseal.pid +ExecStartPre=+/usr/bin/chown chronoseal:chronoseal /run/chronoseal.pid +ExecReload=/bin/kill -HUP $MAINPID +ExecStopPost=+/usr/bin/rm -f /run/chronoseal.pid +PIDFile=/run/chronoseal.pid -Restart=always +Restart=on-failure RestartSec=3 +TimeoutStopSec=30 +KillSignal=SIGTERM + +RuntimeDirectory=chronoseal +RuntimeDirectoryMode=0750 +StateDirectory=chronoseal +StateDirectoryMode=0750 +LogsDirectory=chronoseal +LogsDirectoryMode=0750 +ConfigurationDirectory=chronoseal +ConfigurationDirectoryMode=0750 NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict -ProtectHome=true +ProtectHome=read-only ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true +ProtectClock=true +ProtectHostname=true +ProtectProc=invisible +ProcSubset=pid +PrivateDevices=true +PrivateIPC=true MemoryDenyWriteExecute=true RestrictRealtime=true RestrictSUIDSGID=true +RemoveIPC=true LockPersonality=true SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM +CapabilityBoundingSet= +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 [Install] WantedBy=multi-user.target diff --git a/docs/chronoseal.example.toml b/docs/chronoseal.example.toml new file mode 100644 index 0000000..1dd35da --- /dev/null +++ b/docs/chronoseal.example.toml @@ -0,0 +1,5 @@ +bind = "0.0.0.0:3000" +pid_file = "/run/chronoseal.pid" +db_path = "/var/lib/chronoseal/chronoseal.sqlite" +frontend_dir = "/usr/share/chronoseal/frontend" +log_file = "/var/log/chronoseal/chronoseal.jsonl" diff --git a/scripts/build.sh b/scripts/build.sh old mode 100644 new mode 100755 index 9883cbd..345ff03 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,10 +1,16 @@ #!/bin/bash -set -e +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + echo "Building WASM..." -cd ../wasm -wasm-pack build --target web -mv pkg ../frontend/pkg +cd "$ROOT/wasm" +wasm-pack build --target web --release +rm -rf "$ROOT/frontend/pkg" +mv pkg "$ROOT/frontend/pkg" + echo "Building server..." -cd ../server -cargo build --release -echo "Done." \ No newline at end of file +cd "$ROOT" +cargo build -p chronoseal-server --bin chronoseal --release + +echo "Done." diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..e357a6f --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,52 @@ +#!/bin/sh +set -eu + +CHRONOSEAL_VERSION="${CHRONOSEAL_VERSION:-latest}" +CHRONOSEAL_INSTALL_DIR="${CHRONOSEAL_INSTALL_DIR:-/usr/local/bin}" +CHRONOSEAL_BASE_URL="${CHRONOSEAL_BASE_URL:-https://get.chronoseal.rs/releases}" + +need() { + command -v "$1" >/dev/null 2>&1 || { + echo "chronoseal installer: missing required command: $1" >&2 + exit 1 + } +} + +need uname +need mktemp +need chmod + +arch="$(uname -m)" +case "$arch" in + x86_64|amd64) target="x86_64-unknown-linux-musl" ;; + aarch64|arm64) target="aarch64-unknown-linux-musl" ;; + *) echo "chronoseal installer: unsupported architecture: $arch" >&2; exit 1 ;; +esac + +if command -v curl >/dev/null 2>&1; then + fetch="curl --proto =https --tlsv1.2 -fsSL" +elif command -v wget >/dev/null 2>&1; then + fetch="wget -qO-" +else + echo "chronoseal installer: install curl or wget" >&2 + exit 1 +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +url="$CHRONOSEAL_BASE_URL/$CHRONOSEAL_VERSION/chronoseal-$target.tar.gz" +echo "downloading chronoseal $CHRONOSEAL_VERSION for $target" + +# shellcheck disable=SC2086 +$fetch "$url" | tar -xz -C "$tmp" +chmod 0755 "$tmp/chronoseal" + +if [ "$(id -u)" -eq 0 ]; then + install -m 0755 "$tmp/chronoseal" "$CHRONOSEAL_INSTALL_DIR/chronoseal" +else + sudo install -m 0755 "$tmp/chronoseal" "$CHRONOSEAL_INSTALL_DIR/chronoseal" +fi + +echo "installed: $CHRONOSEAL_INSTALL_DIR/chronoseal" +echo "try: chronoseal --help" diff --git a/scripts/release.sh b/scripts/release.sh old mode 100644 new mode 100755 index f923c19..e4c299b --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,5 +1,12 @@ #!/bin/bash -bash build.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +"$ROOT/scripts/build.sh" + echo "Release artifacts:" -echo " - server/target/release/antibot-server" -echo " - frontend/ (including pkg/)" \ No newline at end of file +echo " - target/release/chronoseal" +echo " - frontend/ (including pkg/)" +echo " - chronoseal.service" +echo " - scripts/install.sh" diff --git a/server/Cargo.toml b/server/Cargo.toml index 073eec6..c7adb37 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -3,15 +3,24 @@ name = "chronoseal-server" version = "0.2.0" edition = "2021" +[[bin]] +name = "chronoseal" +path = "src/main.rs" + [dependencies] shared = { path = "../shared" } axum = "0.7" +clap = { version = "4", features = ["derive", "env", "wrap_help"] } +clap_complete = "4" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml = "0.9" +toml = "0.8" rusqlite = { version = "0.31", features = ["bundled"] } tracing = "0.1" -tracing-subscriber = "0.3" +tracing-appender = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tower = "0.4" tower-http = { version = "0.5", features = ["cors", "fs"] } hex = "0.4" diff --git a/server/src/cli.rs b/server/src/cli.rs new file mode 100644 index 0000000..edb542d --- /dev/null +++ b/server/src/cli.rs @@ -0,0 +1,132 @@ +use clap::{Args, Parser, Subcommand, ValueEnum}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum OutputFormat { + Text, + Json, + Yaml, +} + +#[derive(Debug, Parser)] +#[command( + name = "chronoseal", + version, + about = "Linux-native cryptographic browser attestation service", + long_about = "ChronoSeal runs as a composable Unix service. The CLI is the source of truth for daemon operation, health checks, configuration validation, metrics, and shell integration.", + after_help = "Examples:\n chronoseal\n chronoseal run --bind 127.0.0.1:3000\n chronoseal status --format json\n chronoseal health --config /etc/chronoseal/config.toml\n chronoseal config check --output yaml\n chronoseal generate keypair\n chronoseal completion bash > /etc/bash_completion.d/chronoseal\n\nConfiguration precedence:\n CLI flags > CHRONOSEAL_* environment variables > config file > built-in defaults\n\nDefault config discovery:\n /etc/chronoseal/config.toml, then $XDG_CONFIG_HOME/chronoseal/config.toml, then ~/.config/chronoseal/config.toml" +)] +pub struct Cli { + #[command(flatten)] + pub globals: GlobalArgs, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Debug, Clone, Args)] +pub struct GlobalArgs { + /// Path to config file. + #[arg(long, env = "CHRONOSEAL_CONFIG", global = true)] + pub config: Option, + + /// Output format for machine-readable commands. + #[arg(long, short = 'f', value_enum, default_value = "text", global = true)] + pub format: OutputFormat, + + /// Alias for --format, provided for Unix tool compatibility. + #[arg(long, value_enum, global = true)] + pub output: Option, + + /// Override the logging filter, for example info, chronoseal=debug. + #[arg(long, env = "CHRONOSEAL_LOG", global = true)] + pub log: Option, +} + +impl GlobalArgs { + pub fn output_format(&self) -> OutputFormat { + self.output.unwrap_or(self.format) + } +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Run the ChronoSeal daemon. + #[command(after_help = "Examples:\n chronoseal run\n chronoseal run --bind 127.0.0.1:3000 --frontend-dir /srv/chronoseal/frontend\n CHRONOSEAL_BIND=0.0.0.0:3000 chronoseal run")] + Run(RunArgs), + + /// Report whether the configured daemon is reachable and which PID file is present. + #[command(after_help = "Examples:\n chronoseal status\n chronoseal status --format json\n chronoseal status --pid-file /run/chronoseal.pid")] + Status(RuntimeArgs), + + /// Perform a daemon health probe. + #[command(after_help = "Examples:\n chronoseal health\n chronoseal health --format json\n chronoseal health --bind 127.0.0.1:3000")] + Health(RuntimeArgs), + + /// Validate and print effective configuration. + #[command(subcommand)] + Config(ConfigCommand), + + /// Generate operational material. + #[command(subcommand)] + Generate(GenerateCommand), + + /// Print version and build information. + #[command(after_help = "Examples:\n chronoseal version\n chronoseal version --format json")] + Version, + + /// Print Prometheus metrics from the running daemon. + #[command(after_help = "Examples:\n chronoseal metrics\n chronoseal metrics --bind 127.0.0.1:3000")] + Metrics(RuntimeArgs), + + /// Print service statistics from the running daemon. + #[command(after_help = "Examples:\n chronoseal stats\n chronoseal stats --format json")] + Stats(RuntimeArgs), + + /// Generate shell completions. + #[command(after_help = "Examples:\n chronoseal completion bash\n chronoseal completion zsh > ~/.zfunc/_chronoseal")] + Completion { shell: clap_complete::Shell }, +} + +#[derive(Debug, Clone, Args)] +pub struct RunArgs { + #[command(flatten)] + pub runtime: RuntimeArgs, + + /// SQLite database path. Use ':memory:' for ephemeral state. + #[arg(long, env = "CHRONOSEAL_DB_PATH")] + pub db_path: Option, + + /// Static frontend directory served at /. + #[arg(long, env = "CHRONOSEAL_FRONTEND_DIR")] + pub frontend_dir: Option, + + /// Optional structured JSON log file. + #[arg(long, env = "CHRONOSEAL_LOG_FILE")] + pub log_file: Option, +} + +#[derive(Debug, Clone, Args)] +pub struct RuntimeArgs { + /// Socket address the daemon binds to, or that CLI probes connect to. + #[arg(long, env = "CHRONOSEAL_BIND")] + pub bind: Option, + + /// PID file path. + #[arg(long, env = "CHRONOSEAL_PID_FILE")] + pub pid_file: Option, +} + +#[derive(Debug, Subcommand)] +pub enum ConfigCommand { + /// Validate configuration and print the effective values. + #[command(after_help = "Examples:\n chronoseal config check\n chronoseal config check --config /etc/chronoseal/config.toml\n chronoseal config check --output json")] + Check(RuntimeArgs), +} + +#[derive(Debug, Subcommand)] +pub enum GenerateCommand { + /// Generate an Ed25519 keypair as hex-encoded JSON/YAML/text. + #[command(after_help = "Examples:\n chronoseal generate keypair\n chronoseal generate keypair --format json")] + Keypair, +} diff --git a/server/src/config.rs b/server/src/config.rs new file mode 100644 index 0000000..8a0f8ef --- /dev/null +++ b/server/src/config.rs @@ -0,0 +1,159 @@ +use crate::cli::{RunArgs, RuntimeArgs}; +use serde::{Deserialize, Serialize}; +use std::{ + env, fs, io, + net::SocketAddr, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + pub bind: String, + pub pid_file: PathBuf, + pub db_path: PathBuf, + pub frontend_dir: PathBuf, + pub log_file: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + bind: "0.0.0.0:3000".to_string(), + pid_file: PathBuf::from("/run/chronoseal.pid"), + db_path: default_state_dir().join("chronoseal.sqlite"), + frontend_dir: PathBuf::from("/usr/share/chronoseal/frontend"), + log_file: None, + } + } +} + +impl Config { + pub fn load(config_path: Option<&Path>) -> Result { + let mut config = Self::default(); + + if let Some(path) = config_path.map(Path::to_path_buf).or_else(discover_config_path) { + let raw = fs::read_to_string(&path).map_err(|source| ConfigError::Read { + path: path.clone(), + source, + })?; + config = toml::from_str(&raw).map_err(|source| ConfigError::Parse { + path: path.clone(), + source, + })?; + } + + config.apply_env(); + config.validate()?; + Ok(config) + } + + pub fn apply_runtime_args(&mut self, args: &RuntimeArgs) { + if let Some(bind) = &args.bind { + self.bind.clone_from(bind); + } + if let Some(pid_file) = &args.pid_file { + self.pid_file = pid_file.clone(); + } + } + + pub fn apply_run_args(&mut self, args: &RunArgs) { + self.apply_runtime_args(&args.runtime); + if let Some(db_path) = &args.db_path { + self.db_path = db_path.clone(); + } + if let Some(frontend_dir) = &args.frontend_dir { + self.frontend_dir = frontend_dir.clone(); + } + if let Some(log_file) = &args.log_file { + self.log_file = Some(log_file.clone()); + } + } + + pub fn validate(&self) -> Result<(), ConfigError> { + self.bind + .parse::() + .map_err(|source| ConfigError::InvalidBind { + bind: self.bind.clone(), + source, + })?; + Ok(()) + } + + fn apply_env(&mut self) { + if let Ok(value) = env::var("CHRONOSEAL_BIND") { + self.bind = value; + } + if let Ok(value) = env::var("CHRONOSEAL_PID_FILE") { + self.pid_file = PathBuf::from(value); + } + if let Ok(value) = env::var("CHRONOSEAL_DB_PATH") { + self.db_path = PathBuf::from(value); + } + if let Ok(value) = env::var("CHRONOSEAL_FRONTEND_DIR") { + self.frontend_dir = PathBuf::from(value); + } + if let Ok(value) = env::var("CHRONOSEAL_LOG_FILE") { + self.log_file = Some(PathBuf::from(value)); + } + } +} + +#[derive(Debug)] +pub enum ConfigError { + Read { + path: PathBuf, + source: io::Error, + }, + Parse { + path: PathBuf, + source: toml::de::Error, + }, + InvalidBind { + bind: String, + source: std::net::AddrParseError, + }, +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Read { path, source } => write!(f, "failed to read {}: {source}", path.display()), + Self::Parse { path, source } => { + write!(f, "failed to parse {} as TOML: {source}", path.display()) + } + Self::InvalidBind { bind, source } => write!(f, "invalid bind address {bind}: {source}"), + } + } +} + +impl std::error::Error for ConfigError {} + +fn discover_config_path() -> Option { + user_config_candidates() + .into_iter() + .find(|candidate| candidate.is_file()) +} + +pub fn user_config_candidates() -> Vec { + let mut candidates = vec![PathBuf::from("/etc/chronoseal/config.toml")]; + if let Ok(xdg) = env::var("XDG_CONFIG_HOME") { + candidates.push(PathBuf::from(xdg).join("chronoseal/config.toml")); + } else if let Ok(home) = env::var("HOME") { + candidates.push(PathBuf::from(home).join(".config/chronoseal/config.toml")); + } + candidates +} + +fn default_state_dir() -> PathBuf { + if let Ok(value) = env::var("CHRONOSEAL_STATE_DIR") { + return PathBuf::from(value); + } + if let Ok(value) = env::var("XDG_STATE_HOME") { + return PathBuf::from(value).join("chronoseal"); + } + if let Ok(home) = env::var("HOME") { + return PathBuf::from(home).join(".local/state/chronoseal"); + } + PathBuf::from("/var/lib/chronoseal") +} diff --git a/server/src/main.rs b/server/src/main.rs index c1d395f..969afe8 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,47 +1,143 @@ mod cleanup; +mod cli; +mod config; mod crypto; mod fingerprint; mod middleware; +mod output; mod ratelimit; mod routes; +mod runtime; mod session; mod storage; mod trust; mod vm; -use axum::Router; -use std::sync::Arc; -use tokio::sync::Mutex; -use tracing::info; - -use session::AppState; +use clap::{CommandFactory, Parser}; +use cli::{Cli, Command, ConfigCommand, GenerateCommand}; +use config::Config; +use std::path::PathBuf; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; #[tokio::main] async fn main() { - tracing_subscriber::fmt::init(); + if let Err(err) = try_main().await { + eprintln!("chronoseal: {err}"); + std::process::exit(1); + } +} - let conn = storage::init_db().expect("DB init"); - let state = Arc::new(AppState { - db: Mutex::new(conn), - rate_limiter: Mutex::new(ratelimit::RateLimiter::new( - shared::constants::RATE_LIMIT_COUNT, - shared::constants::RATE_LIMIT_WINDOW_SECS, - )), - }); +async fn try_main() -> Result<(), Box> { + let cli = Cli::parse(); + let log_filter = cli.globals.log.as_deref().unwrap_or("info"); + let log_file = log_file_for_command(&cli); + let _log_guard = init_logging(log_filter, log_file)?; - // Periodic cleanup - let bg_state = state.clone(); - tokio::spawn(async move { cleanup::cleanup_loop(bg_state).await }); + match &cli.command { + None | Some(Command::Run(_)) => { + let mut config = Config::load(cli.globals.config.as_deref())?; + if let Some(Command::Run(args)) = &cli.command { + config.apply_run_args(args); + config.validate()?; + } + runtime::run_daemon(config).await?; + } + Some(Command::Status(args)) => { + let mut config = Config::load(cli.globals.config.as_deref())?; + config.apply_runtime_args(args); + config.validate()?; + output::print(cli.globals.output_format(), &runtime::probe_status(&config))?; + } + Some(Command::Health(args)) => { + let mut config = Config::load(cli.globals.config.as_deref())?; + config.apply_runtime_args(args); + config.validate()?; + let report = runtime::probe_health(&config); + let healthy = report.status == "healthy"; + output::print(cli.globals.output_format(), &report)?; + if !healthy { + std::process::exit(2); + } + } + Some(Command::Config(ConfigCommand::Check(args))) => { + let mut config = Config::load(cli.globals.config.as_deref())?; + config.apply_runtime_args(args); + config.validate()?; + output::print(cli.globals.output_format(), &config)?; + } + Some(Command::Generate(GenerateCommand::Keypair)) => { + output::print(cli.globals.output_format(), &runtime::generate_keypair())?; + } + Some(Command::Version) => { + output::print(cli.globals.output_format(), &runtime::version())?; + } + Some(Command::Metrics(args)) => { + let mut config = Config::load(cli.globals.config.as_deref())?; + config.apply_runtime_args(args); + config.validate()?; + print!("{}", runtime::fetch_metrics(&config)?); + } + Some(Command::Stats(args)) => { + let mut config = Config::load(cli.globals.config.as_deref())?; + config.apply_runtime_args(args); + config.validate()?; + output::print(cli.globals.output_format(), &runtime::fetch_stats(&config)?)?; + } + Some(Command::Completion { shell }) => { + let mut command = Cli::command(); + let name = command.get_name().to_string(); + clap_complete::generate(*shell, &mut command, name, &mut std::io::stdout()); + } + } + Ok(()) +} - let app = Router::new() - .route("/init", axum::routing::post(routes::init::handler)) - .route("/hb", axum::routing::post(routes::heartbeat::handler)) - .nest_service("/", tower_http::services::ServeDir::new("../frontend")) - .layer(tower_http::cors::CorsLayer::permissive()) - .layer(axum::middleware::from_fn(middleware::log_request)) - .with_state(state); +fn log_file_for_command(cli: &Cli) -> Option { + match &cli.command { + None => Config::load(cli.globals.config.as_deref()) + .ok() + .and_then(|config| config.log_file), + Some(Command::Run(args)) => { + let mut config = Config::load(cli.globals.config.as_deref()).ok()?; + config.apply_run_args(args); + config.log_file + } + _ => None, + } +} - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); - info!("Server running on :3000"); - axum::serve(listener, app).await.unwrap(); -} \ No newline at end of file +fn init_logging( + filter: &str, + log_file: Option, +) -> Result, Box> { + let env_filter = EnvFilter::try_new(filter)?; + if let Some(path) = log_file { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let directory = path.parent().unwrap_or_else(|| std::path::Path::new(".")); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("chronoseal.jsonl"); + let appender = tracing_appender::rolling::never(directory, file_name); + let (writer, guard) = tracing_appender::non_blocking(appender); + tracing_subscriber::registry() + .with(env_filter) + .with(tracing_subscriber::fmt::layer().with_target(false)) + .with( + tracing_subscriber::fmt::layer() + .json() + .with_target(false) + .with_writer(writer), + ) + .init(); + Ok(Some(guard)) + } else { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_target(false) + .init(); + Ok(None) + } +} diff --git a/server/src/output.rs b/server/src/output.rs new file mode 100644 index 0000000..241c253 --- /dev/null +++ b/server/src/output.rs @@ -0,0 +1,18 @@ +use crate::cli::OutputFormat; +use serde::Serialize; + +pub fn print(format: OutputFormat, value: &T) -> Result<(), Box> +where + T: Serialize + TextOutput, +{ + match format { + OutputFormat::Text => println!("{}", value.to_text()), + OutputFormat::Json => println!("{}", serde_json::to_string_pretty(value)?), + OutputFormat::Yaml => print!("{}", serde_yaml::to_string(value)?), + } + Ok(()) +} + +pub trait TextOutput { + fn to_text(&self) -> String; +} diff --git a/server/src/runtime.rs b/server/src/runtime.rs new file mode 100644 index 0000000..b45fdd9 --- /dev/null +++ b/server/src/runtime.rs @@ -0,0 +1,323 @@ +use crate::{ + config::Config, + output::TextOutput, + ratelimit::RateLimiter, + routes, session, + storage::{self, StoreStats}, +}; +use axum::{http::StatusCode, response::IntoResponse, routing::get, Json, Router}; +use serde::Serialize; +use std::{ + fs, + io::{Read, Write}, + net::{SocketAddr, TcpStream}, + path::Path, + sync::Arc, + time::Duration, +}; +use tokio::sync::{Mutex, Notify}; +use tracing::{error, info, warn}; + +#[derive(Debug, Serialize)] +pub struct HealthReport { + pub status: &'static str, + pub bind: String, +} + +impl TextOutput for HealthReport { + fn to_text(&self) -> String { + format!("{}\nbind={}", self.status, self.bind) + } +} + +#[derive(Debug, Serialize)] +pub struct StatusReport { + pub running: bool, + pub healthy: bool, + pub bind: String, + pub pid_file: String, + pub pid: Option, +} + +impl TextOutput for StatusReport { + fn to_text(&self) -> String { + let pid = self.pid.map_or_else(|| "unknown".to_string(), |pid| pid.to_string()); + format!( + "running={}\nhealthy={}\nbind={}\npid_file={}\npid={}", + self.running, self.healthy, self.bind, self.pid_file, pid + ) + } +} + +#[derive(Debug, Serialize)] +pub struct VersionReport { + pub name: &'static str, + pub version: &'static str, + pub target: &'static str, +} + +impl TextOutput for VersionReport { + fn to_text(&self) -> String { + format!("{} {}", self.name, self.version) + } +} + +#[derive(Debug, Serialize)] +pub struct KeypairReport { + pub algorithm: &'static str, + pub public_key_hex: String, + pub private_key_hex: String, +} + +impl TextOutput for KeypairReport { + fn to_text(&self) -> String { + format!( + "algorithm={}\npublic_key_hex={}\nprivate_key_hex={}", + self.algorithm, self.public_key_hex, self.private_key_hex + ) + } +} + +impl TextOutput for Config { + fn to_text(&self) -> String { + format!( + "bind={}\npid_file={}\ndb_path={}\nfrontend_dir={}\nlog_file={}", + self.bind, + self.pid_file.display(), + self.db_path.display(), + self.frontend_dir.display(), + self.log_file + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "none".to_string()) + ) + } +} + +impl TextOutput for StoreStats { + fn to_text(&self) -> String { + format!( + "sessions={}\nexpired_sessions={}\nmax_chain_length={}", + self.sessions, self.expired_sessions, self.max_chain_length + ) + } +} + +pub async fn run_daemon(config: Config) -> Result<(), Box> { + install_pid_file(&config.pid_file)?; + + let conn = storage::init_db(&config.db_path)?; + let state = Arc::new(session::AppState { + db: Mutex::new(conn), + rate_limiter: Mutex::new(RateLimiter::new( + shared::constants::RATE_LIMIT_COUNT, + shared::constants::RATE_LIMIT_WINDOW_SECS, + )), + }); + + let bg_state = state.clone(); + tokio::spawn(async move { crate::cleanup::cleanup_loop(bg_state).await }); + + let app = Router::new() + .route("/init", axum::routing::post(routes::init::handler)) + .route("/hb", axum::routing::post(routes::heartbeat::handler)) + .route("/health", get(health_handler)) + .route("/metrics", get(metrics_handler)) + .route("/stats", get(stats_handler)) + .nest_service("/", tower_http::services::ServeDir::new(&config.frontend_dir)) + .layer(tower_http::cors::CorsLayer::permissive()) + .layer(axum::middleware::from_fn(crate::middleware::log_request)) + .with_state(state); + + let addr: SocketAddr = config.bind.parse()?; + let listener = tokio::net::TcpListener::bind(addr).await?; + info!(bind = %config.bind, "chronoseal daemon started"); + + let shutdown = signal_task(config.clone()); + let result = axum::serve(listener, app) + .with_graceful_shutdown(shutdown) + .await; + + remove_pid_file(&config.pid_file); + result?; + info!("chronoseal daemon stopped"); + Ok(()) +} + +pub fn probe_health(config: &Config) -> HealthReport { + if http_get(&config.bind, "/health").is_ok() { + HealthReport { + status: "healthy", + bind: config.bind.clone(), + } + } else { + HealthReport { + status: "unreachable", + bind: config.bind.clone(), + } + } +} + +pub fn probe_status(config: &Config) -> StatusReport { + let pid = read_pid(&config.pid_file); + let healthy = http_get(&config.bind, "/health").is_ok(); + StatusReport { + running: pid.is_some() || healthy, + healthy, + bind: config.bind.clone(), + pid_file: config.pid_file.display().to_string(), + pid, + } +} + +pub fn fetch_metrics(config: &Config) -> Result> { + http_get(&config.bind, "/metrics") +} + +pub fn fetch_stats(config: &Config) -> Result> { + let body = http_get(&config.bind, "/stats")?; + Ok(serde_json::from_str(&body)?) +} + +pub fn generate_keypair() -> KeypairReport { + let private_key = rand::random::<[u8; 32]>(); + let signing_key = ed25519_dalek::SigningKey::from_bytes(&private_key); + let verifying_key = signing_key.verifying_key(); + KeypairReport { + algorithm: "ed25519", + public_key_hex: hex::encode(verifying_key.to_bytes()), + private_key_hex: hex::encode(private_key), + } +} + +pub fn version() -> VersionReport { + VersionReport { + name: "chronoseal", + version: env!("CARGO_PKG_VERSION"), + target: std::env::consts::ARCH, + } +} + +async fn health_handler() -> impl IntoResponse { + (StatusCode::OK, Json(serde_json::json!({ "status": "healthy" }))) +} + +async fn stats_handler( + axum::extract::State(state): axum::extract::State>, +) -> Result, (StatusCode, String)> { + let db = state.db.lock().await; + storage::stats(&db) + .map(Json) + .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string())) +} + +async fn metrics_handler( + axum::extract::State(state): axum::extract::State>, +) -> Result { + let db = state.db.lock().await; + storage::stats(&db) + .map(|stats| { + format!( + "# HELP chronoseal_sessions Active ChronoSeal sessions\n# TYPE chronoseal_sessions gauge\nchronoseal_sessions {}\n# HELP chronoseal_expired_sessions Expired sessions not yet removed\n# TYPE chronoseal_expired_sessions gauge\nchronoseal_expired_sessions {}\n# HELP chronoseal_max_chain_length Maximum heartbeat chain length\n# TYPE chronoseal_max_chain_length gauge\nchronoseal_max_chain_length {}\n", + stats.sessions, stats.expired_sessions, stats.max_chain_length + ) + }) + .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string())) +} + +async fn signal_task(config: Config) { + let shutdown = Arc::new(Notify::new()); + + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + + let shutdown_term = shutdown.clone(); + tokio::spawn(async move { + let mut sigterm = signal(SignalKind::terminate()).expect("install SIGTERM handler"); + sigterm.recv().await; + info!("received SIGTERM; shutting down gracefully"); + shutdown_term.notify_one(); + }); + + let shutdown_int = shutdown.clone(); + tokio::spawn(async move { + if tokio::signal::ctrl_c().await.is_ok() { + info!("received interrupt; shutting down gracefully"); + shutdown_int.notify_one(); + } + }); + + let hup_config = config.clone(); + tokio::spawn(async move { + let mut sighup = signal(SignalKind::hangup()).expect("install SIGHUP handler"); + while sighup.recv().await.is_some() { + match Config::load(None) { + Ok(reloaded) => info!( + bind = %reloaded.bind, + db_path = %reloaded.db_path.display(), + "received SIGHUP; configuration reloaded" + ), + Err(err) => warn!(error = %err, "received SIGHUP; configuration reload failed"), + } + let _ = &hup_config; + } + }); + + tokio::spawn(async move { + let mut sigusr1 = signal(SignalKind::user_defined1()).expect("install SIGUSR1 handler"); + while sigusr1.recv().await.is_some() { + info!("received SIGUSR1; stats are available via chronoseal stats or /stats"); + } + }); + } + + #[cfg(not(unix))] + { + if tokio::signal::ctrl_c().await.is_ok() { + shutdown.notify_one(); + } + } + + shutdown.notified().await; +} + +fn install_pid_file(path: &Path) -> Result<(), Box> { + if let Some(parent) = path.parent() { + if let Err(err) = fs::create_dir_all(parent) { + warn!(path = %parent.display(), error = %err, "could not create PID directory"); + } + } + match fs::write(path, std::process::id().to_string()) { + Ok(()) => Ok(()), + Err(err) => { + warn!(path = %path.display(), error = %err, "could not write PID file"); + Ok(()) + } + } +} + +fn remove_pid_file(path: &Path) { + if let Err(err) = fs::remove_file(path) { + if err.kind() != std::io::ErrorKind::NotFound { + error!(path = %path.display(), error = %err, "could not remove PID file"); + } + } +} + +fn read_pid(path: &Path) -> Option { + fs::read_to_string(path).ok()?.trim().parse().ok() +} + +fn http_get(bind: &str, path: &str) -> Result> { + let mut stream = TcpStream::connect_timeout(&bind.parse()?, Duration::from_secs(2))?; + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + stream.write_all(format!("GET {path} HTTP/1.1\r\nHost: chronoseal\r\nConnection: close\r\n\r\n").as_bytes())?; + + let mut response = String::new(); + stream.read_to_string(&mut response)?; + let (_, body) = response + .split_once("\r\n\r\n") + .ok_or("daemon returned an invalid HTTP response")?; + Ok(body.to_string()) +} diff --git a/server/src/storage.rs b/server/src/storage.rs index fe7ffc7..748fe08 100644 --- a/server/src/storage.rs +++ b/server/src/storage.rs @@ -1,8 +1,26 @@ use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; -pub fn init_db() -> Result { - let conn = Connection::open_in_memory()?; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoreStats { + pub sessions: u64, + pub expired_sessions: u64, + pub max_chain_length: u64, +} + +pub fn init_db(path: &Path) -> Result { + if path == Path::new(":memory:") { + return init_schema(Connection::open_in_memory()?); + } + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + init_schema(Connection::open(path)?) +} + +fn init_schema(conn: Connection) -> Result { conn.execute_batch( "CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, @@ -18,6 +36,26 @@ pub fn init_db() -> Result { Ok(conn) } +pub fn stats(conn: &Connection) -> Result { + let now = current_time_ms(); + let sessions = conn.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?; + let expired_sessions = conn.query_row( + "SELECT COUNT(*) FROM sessions WHERE expires_at < ?1", + [now], + |row| row.get(0), + )?; + let max_chain_length = conn.query_row( + "SELECT COALESCE(MAX(chain_length), 0) FROM sessions", + [], + |row| row.get(0), + )?; + Ok(StoreStats { + sessions, + expired_sessions, + max_chain_length, + }) +} + pub fn current_time_ms() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64 -} \ No newline at end of file +}