Compare commits
2 commits
e225ffe997
...
e8b60519e7
| Author | SHA1 | Date | |
|---|---|---|---|
| e8b60519e7 | |||
| 5183130427 |
26 changed files with 873 additions and 2131 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
remote/target
|
target/
|
||||||
pi/pi-service/target
|
result
|
||||||
|
result-*
|
||||||
.direnv
|
.direnv
|
||||||
|
|
|
||||||
88
remote/Cargo.lock → Cargo.lock
generated
88
remote/Cargo.lock → Cargo.lock
generated
|
|
@ -107,6 +107,12 @@ version = "0.22.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
|
|
@ -142,9 +148,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.56"
|
version = "1.2.57"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"shlex",
|
"shlex",
|
||||||
|
|
@ -393,6 +399,15 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gpiod-core"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15a60e3beb5444643d049a3f8769b47ce246ec1f57e6cd1aed1e417d57a47110"
|
||||||
|
dependencies = [
|
||||||
|
"nix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
|
|
@ -751,6 +766,35 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "noisebell"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"axum",
|
||||||
|
"libc",
|
||||||
|
"noisebell-common",
|
||||||
|
"reqwest",
|
||||||
|
"sd-notify",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"tokio-gpiod",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "noisebell-cache"
|
name = "noisebell-cache"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -774,6 +818,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -783,6 +828,7 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"noisebell-common",
|
"noisebell-common",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serenity",
|
"serenity",
|
||||||
|
|
@ -825,9 +871,9 @@ checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
version = "1.21.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
|
|
@ -1094,7 +1140,7 @@ version = "0.33.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1c6d5e5acb6f6129fe3f7ba0a7fc77bca1942cb568535e18e7bc40262baf3110"
|
checksum = "1c6d5e5acb6f6129fe3f7ba0a7fc77bca1942cb568535e18e7bc40262baf3110"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.0",
|
||||||
"fallible-iterator",
|
"fallible-iterator",
|
||||||
"fallible-streaming-iterator",
|
"fallible-streaming-iterator",
|
||||||
"hashlink",
|
"hashlink",
|
||||||
|
|
@ -1180,6 +1226,15 @@ version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sd-notify"
|
||||||
|
version = "0.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b943eadf71d8b69e661330cb0e2656e31040acf21ee7708e2c238a0ec6af2bf4"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secrecy"
|
name = "secrecy"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
|
|
@ -1274,7 +1329,7 @@ dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
"bitflags",
|
"bitflags 2.11.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
|
|
@ -1492,9 +1547,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyvec"
|
name = "tinyvec"
|
||||||
version = "1.10.0"
|
version = "1.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
|
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tinyvec_macros",
|
"tinyvec_macros",
|
||||||
]
|
]
|
||||||
|
|
@ -1521,6 +1576,17 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-gpiod"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce15fa0021a7acacd2be506f72aeb5044a0a8b53d684963f133b37ace5c57f47"
|
||||||
|
dependencies = [
|
||||||
|
"gpiod-core",
|
||||||
|
"libc",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-macros"
|
name = "tokio-macros"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
|
|
@ -1604,7 +1670,7 @@ version = "0.6.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags 2.11.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
|
|
@ -1675,9 +1741,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-subscriber"
|
name = "tracing-subscriber"
|
||||||
version = "0.3.22"
|
version = "0.3.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"matchers",
|
"matchers",
|
||||||
"nu-ansi-term",
|
"nu-ansi-term",
|
||||||
9
Cargo.toml
Normal file
9
Cargo.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"pi/pi-service",
|
||||||
|
"remote/noisebell-common",
|
||||||
|
"remote/cache-service",
|
||||||
|
"remote/discord-bot",
|
||||||
|
"remote/rss-service",
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
103
flake.lock
generated
103
flake.lock
generated
|
|
@ -25,26 +25,11 @@
|
||||||
},
|
},
|
||||||
"crane": {
|
"crane": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773189535,
|
"lastModified": 1773857772,
|
||||||
"narHash": "sha256-E1G/Or6MWeP+L6mpQ0iTFLpzSzlpGrITfU2220Gq47g=",
|
"narHash": "sha256-5xsK26KRHf0WytBtsBnQYC/lTWDhQuT57HJ7SzuqZcM=",
|
||||||
"owner": "ipetkov",
|
"owner": "ipetkov",
|
||||||
"repo": "crane",
|
"repo": "crane",
|
||||||
"rev": "6fa2fb4cf4a89ba49fc9dd5a3eb6cde99d388269",
|
"rev": "b556d7bbae5ff86e378451511873dfd07e4504cd",
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "ipetkov",
|
|
||||||
"repo": "crane",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"crane_2": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1773115265,
|
|
||||||
"narHash": "sha256-5fDkKTYEgue2klksd52WvcXfZdY1EIlbk0QggAwpFog=",
|
|
||||||
"owner": "ipetkov",
|
|
||||||
"repo": "crane",
|
|
||||||
"rev": "27711550d109bf6236478dc9f53b9e29c1a374c5",
|
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -112,98 +97,26 @@
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1773646010,
|
|
||||||
"narHash": "sha256-iYrs97hS7p5u4lQzuNWzuALGIOdkPXvjz7bviiBjUu8=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "5b2c2d84341b2afb5647081c1386a80d7a8d8605",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pi-service": {
|
|
||||||
"inputs": {
|
|
||||||
"crane": "crane",
|
|
||||||
"nixpkgs": "nixpkgs_2",
|
|
||||||
"rust-overlay": "rust-overlay"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"path": "./pi/pi-service",
|
|
||||||
"type": "path"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"path": "./pi/pi-service",
|
|
||||||
"type": "path"
|
|
||||||
},
|
|
||||||
"parent": []
|
|
||||||
},
|
|
||||||
"remote": {
|
|
||||||
"inputs": {
|
|
||||||
"crane": "crane_2",
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
],
|
|
||||||
"rust-overlay": "rust-overlay_2"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"path": "./remote",
|
|
||||||
"type": "path"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"path": "./remote",
|
|
||||||
"type": "path"
|
|
||||||
},
|
|
||||||
"parent": []
|
|
||||||
},
|
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"agenix": "agenix",
|
"agenix": "agenix",
|
||||||
|
"crane": "crane",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"pi-service": "pi-service",
|
"rust-overlay": "rust-overlay"
|
||||||
"remote": "remote"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rust-overlay": {
|
"rust-overlay": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"pi-service",
|
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1773716879,
|
"lastModified": 1773803479,
|
||||||
"narHash": "sha256-vXCTasEzzTTd0ZGEuyle20H2hjRom66JeNr7i2ktHD0=",
|
"narHash": "sha256-GD6i1F2vrSxbsmbS92+8+x3DbHOJ+yrS78Pm4xigW4M=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "1a9ddeb45c5751b800331363703641b84d1f41f0",
|
"rev": "f17186f52e82ec5cf40920b58eac63b78692ac7c",
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "oxalica",
|
|
||||||
"repo": "rust-overlay",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rust-overlay_2": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": [
|
|
||||||
"remote",
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1773115373,
|
|
||||||
"narHash": "sha256-bfK9FJFcQth6f3ydYggS5m0z2NRGF/PY6Y2XgZDJ6pg=",
|
|
||||||
"owner": "oxalica",
|
|
||||||
"repo": "rust-overlay",
|
|
||||||
"rev": "1924b4672a2b8e4aee6e6652ec2e59a8d3c5648e",
|
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
159
flake.nix
159
flake.nix
|
|
@ -7,36 +7,135 @@
|
||||||
url = "github:ryantm/agenix";
|
url = "github:ryantm/agenix";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
pi-service.url = "path:./pi/pi-service";
|
crane.url = "github:ipetkov/crane";
|
||||||
remote.url = "path:./remote";
|
rust-overlay = {
|
||||||
remote.inputs.nixpkgs.follows = "nixpkgs";
|
url = "github:oxalica/rust-overlay";
|
||||||
};
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
|
||||||
outputs = { self, nixpkgs, agenix, pi-service, remote }: {
|
|
||||||
nixosModules = remote.nixosModules;
|
|
||||||
|
|
||||||
nixosConfigurations.pi = nixpkgs.lib.nixosSystem {
|
|
||||||
system = "aarch64-linux";
|
|
||||||
modules = [
|
|
||||||
agenix.nixosModules.default
|
|
||||||
(import ./pi/module.nix {
|
|
||||||
pkg = pi-service.packages.aarch64-linux.default;
|
|
||||||
rev = self.shortRev or "dirty";
|
|
||||||
})
|
|
||||||
./pi/configuration.nix
|
|
||||||
./pi/hardware-configuration.nix
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
nixosConfigurations.bootstrap = nixpkgs.lib.nixosSystem {
|
|
||||||
system = "aarch64-linux";
|
|
||||||
modules = [ ./pi/bootstrap.nix ];
|
|
||||||
};
|
|
||||||
|
|
||||||
devShells.x86_64-linux.default = let
|
|
||||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
|
||||||
in pkgs.mkShell {
|
|
||||||
packages = [ agenix.packages.x86_64-linux.default ];
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, agenix, crane, rust-overlay }:
|
||||||
|
let
|
||||||
|
system = "x86_64-linux";
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
overlays = [ rust-overlay.overlays.default ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# --- Remote services (x86_64-linux) ---
|
||||||
|
|
||||||
|
rustToolchain = pkgs.rust-bin.stable.latest.default;
|
||||||
|
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||||
|
|
||||||
|
src = pkgs.lib.cleanSourceWith {
|
||||||
|
src = ./.;
|
||||||
|
filter = path: type:
|
||||||
|
(builtins.match ".*\.png$" path != null) || (craneLib.filterCargoSources path type);
|
||||||
|
};
|
||||||
|
|
||||||
|
remoteArgs = {
|
||||||
|
inherit src;
|
||||||
|
pname = "noisebell";
|
||||||
|
version = "0.1.0";
|
||||||
|
strictDeps = true;
|
||||||
|
doCheck = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
remoteArtifacts = craneLib.buildDepsOnly remoteArgs;
|
||||||
|
|
||||||
|
buildRemoteMember = name: craneLib.buildPackage (remoteArgs // {
|
||||||
|
cargoArtifacts = remoteArtifacts;
|
||||||
|
cargoExtraArgs = "-p ${name}";
|
||||||
|
});
|
||||||
|
|
||||||
|
noisebell-cache = buildRemoteMember "noisebell-cache";
|
||||||
|
noisebell-discord = buildRemoteMember "noisebell-discord";
|
||||||
|
noisebell-rss = buildRemoteMember "noisebell-rss";
|
||||||
|
|
||||||
|
# --- Pi service (cross-compiled to aarch64-linux) ---
|
||||||
|
|
||||||
|
crossPkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
crossSystem.config = "aarch64-unknown-linux-gnu";
|
||||||
|
overlays = [ rust-overlay.overlays.default ];
|
||||||
|
};
|
||||||
|
|
||||||
|
piRustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||||
|
targets = [ "aarch64-unknown-linux-gnu" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
piCraneLib = (crane.mkLib pkgs).overrideToolchain piRustToolchain;
|
||||||
|
|
||||||
|
piArgs = {
|
||||||
|
inherit src;
|
||||||
|
pname = "noisebell-pi";
|
||||||
|
version = "0.1.0";
|
||||||
|
strictDeps = true;
|
||||||
|
doCheck = false;
|
||||||
|
|
||||||
|
CARGO_BUILD_TARGET = "aarch64-unknown-linux-gnu";
|
||||||
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER =
|
||||||
|
"${crossPkgs.stdenv.cc.targetPrefix}cc";
|
||||||
|
TARGET_CC = "${crossPkgs.stdenv.cc.targetPrefix}cc";
|
||||||
|
CC_aarch64_unknown_linux_gnu = "${crossPkgs.stdenv.cc.targetPrefix}cc";
|
||||||
|
HOST_CC = "${pkgs.stdenv.cc.nativePrefix}cc";
|
||||||
|
|
||||||
|
depsBuildBuild = [ crossPkgs.stdenv.cc ];
|
||||||
|
cargoExtraArgs = "-p noisebell";
|
||||||
|
};
|
||||||
|
|
||||||
|
piArtifacts = piCraneLib.buildDepsOnly piArgs;
|
||||||
|
|
||||||
|
noisebell-pi = piCraneLib.buildPackage (piArgs // {
|
||||||
|
cargoArtifacts = piArtifacts;
|
||||||
|
});
|
||||||
|
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages.${system} = {
|
||||||
|
inherit noisebell-cache noisebell-discord noisebell-rss;
|
||||||
|
default = noisebell-cache;
|
||||||
|
};
|
||||||
|
|
||||||
|
packages.aarch64-linux = {
|
||||||
|
noisebell = noisebell-pi;
|
||||||
|
default = noisebell-pi;
|
||||||
|
};
|
||||||
|
|
||||||
|
nixosModules = {
|
||||||
|
cache = import ./remote/cache-service/module.nix noisebell-cache;
|
||||||
|
discord = import ./remote/discord-bot/module.nix noisebell-discord;
|
||||||
|
rss = import ./remote/rss-service/module.nix noisebell-rss;
|
||||||
|
default = { imports = [
|
||||||
|
(import ./remote/cache-service/module.nix noisebell-cache)
|
||||||
|
(import ./remote/discord-bot/module.nix noisebell-discord)
|
||||||
|
(import ./remote/rss-service/module.nix noisebell-rss)
|
||||||
|
]; };
|
||||||
|
};
|
||||||
|
|
||||||
|
nixosConfigurations.pi = nixpkgs.lib.nixosSystem {
|
||||||
|
system = "aarch64-linux";
|
||||||
|
modules = [
|
||||||
|
agenix.nixosModules.default
|
||||||
|
(import ./pi/module.nix {
|
||||||
|
pkg = noisebell-pi;
|
||||||
|
rev = self.shortRev or "dirty";
|
||||||
|
})
|
||||||
|
./pi/configuration.nix
|
||||||
|
./pi/hardware-configuration.nix
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
nixosConfigurations.bootstrap = nixpkgs.lib.nixosSystem {
|
||||||
|
system = "aarch64-linux";
|
||||||
|
modules = [ ./pi/bootstrap.nix ];
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells.${system}.default = craneLib.devShell {
|
||||||
|
packages = [
|
||||||
|
pkgs.rust-analyzer
|
||||||
|
agenix.packages.${system}.default
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1663
pi/pi-service/Cargo.lock
generated
1663
pi/pi-service/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,7 @@ edition = "2021"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
noisebell-common = { path = "../../remote/noisebell-common" }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
sd-notify = "0.4"
|
sd-notify = "0.4"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
|
|
||||||
64
pi/pi-service/flake.lock
generated
64
pi/pi-service/flake.lock
generated
|
|
@ -1,64 +0,0 @@
|
||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"crane": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1773189535,
|
|
||||||
"narHash": "sha256-E1G/Or6MWeP+L6mpQ0iTFLpzSzlpGrITfU2220Gq47g=",
|
|
||||||
"owner": "ipetkov",
|
|
||||||
"repo": "crane",
|
|
||||||
"rev": "6fa2fb4cf4a89ba49fc9dd5a3eb6cde99d388269",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "ipetkov",
|
|
||||||
"repo": "crane",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1773646010,
|
|
||||||
"narHash": "sha256-iYrs97hS7p5u4lQzuNWzuALGIOdkPXvjz7bviiBjUu8=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "5b2c2d84341b2afb5647081c1386a80d7a8d8605",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"crane": "crane",
|
|
||||||
"nixpkgs": "nixpkgs",
|
|
||||||
"rust-overlay": "rust-overlay"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rust-overlay": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1773716879,
|
|
||||||
"narHash": "sha256-vXCTasEzzTTd0ZGEuyle20H2hjRom66JeNr7i2ktHD0=",
|
|
||||||
"owner": "oxalica",
|
|
||||||
"repo": "rust-overlay",
|
|
||||||
"rev": "1a9ddeb45c5751b800331363703641b84d1f41f0",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "oxalica",
|
|
||||||
"repo": "rust-overlay",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
{
|
|
||||||
description = "Noisebell - GPIO door monitor service";
|
|
||||||
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
|
||||||
crane.url = "github:ipetkov/crane";
|
|
||||||
rust-overlay = {
|
|
||||||
url = "github:oxalica/rust-overlay";
|
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs = { self, nixpkgs, crane, rust-overlay }:
|
|
||||||
let
|
|
||||||
forSystem = system:
|
|
||||||
let
|
|
||||||
pkgs = import nixpkgs {
|
|
||||||
inherit system;
|
|
||||||
overlays = [ rust-overlay.overlays.default ];
|
|
||||||
};
|
|
||||||
|
|
||||||
crossPkgs = import nixpkgs {
|
|
||||||
inherit system;
|
|
||||||
crossSystem.config = "aarch64-unknown-linux-gnu";
|
|
||||||
overlays = [ rust-overlay.overlays.default ];
|
|
||||||
};
|
|
||||||
|
|
||||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
|
||||||
targets = [ "aarch64-unknown-linux-gnu" ];
|
|
||||||
};
|
|
||||||
|
|
||||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
|
||||||
|
|
||||||
src = craneLib.cleanCargoSource ./.;
|
|
||||||
|
|
||||||
commonArgs = {
|
|
||||||
inherit src;
|
|
||||||
strictDeps = true;
|
|
||||||
doCheck = false;
|
|
||||||
|
|
||||||
CARGO_BUILD_TARGET = "aarch64-unknown-linux-gnu";
|
|
||||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER =
|
|
||||||
"${crossPkgs.stdenv.cc.targetPrefix}cc";
|
|
||||||
|
|
||||||
TARGET_CC = "${crossPkgs.stdenv.cc.targetPrefix}cc";
|
|
||||||
CC_aarch64_unknown_linux_gnu = "${crossPkgs.stdenv.cc.targetPrefix}cc";
|
|
||||||
|
|
||||||
HOST_CC = "${pkgs.stdenv.cc.nativePrefix}cc";
|
|
||||||
|
|
||||||
depsBuildBuild = [ crossPkgs.stdenv.cc ];
|
|
||||||
};
|
|
||||||
|
|
||||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
|
||||||
|
|
||||||
noisebell = craneLib.buildPackage (commonArgs // {
|
|
||||||
inherit cargoArtifacts;
|
|
||||||
});
|
|
||||||
in
|
|
||||||
{
|
|
||||||
packages.aarch64-linux.default = noisebell;
|
|
||||||
packages.aarch64-linux.noisebell = noisebell;
|
|
||||||
|
|
||||||
devShells.${system}.default = craneLib.devShell {
|
|
||||||
packages = [ pkgs.rust-analyzer ];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
in
|
|
||||||
forSystem "x86_64-linux";
|
|
||||||
}
|
|
||||||
|
|
@ -7,6 +7,7 @@ use axum::extract::State;
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
|
use noisebell_common::{validate_bearer, WebhookPayload};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tokio_gpiod::{Bias, Chip, Edge, EdgeDetect, Options};
|
use tokio_gpiod::{Bias, Chip, Edge, EdgeDetect, Options};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
@ -21,14 +22,6 @@ struct AppState {
|
||||||
inbound_api_key: String,
|
inbound_api_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_bearer(headers: &HeaderMap, expected: &str) -> bool {
|
|
||||||
headers
|
|
||||||
.get("authorization")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.map(|v| v.strip_prefix("Bearer ").unwrap_or("") == expected)
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct StatusResponse {
|
struct StatusResponse {
|
||||||
status: &'static str,
|
status: &'static str,
|
||||||
|
|
@ -324,7 +317,7 @@ async fn main() -> Result<()> {
|
||||||
let status = status_str(new_open);
|
let status = status_str(new_open);
|
||||||
info!(status, timestamp, "state changed");
|
info!(status, timestamp, "state changed");
|
||||||
|
|
||||||
let payload = serde_json::json!({ "status": status, "timestamp": timestamp });
|
let payload = WebhookPayload { status: status.to_string(), timestamp };
|
||||||
|
|
||||||
for attempt in 0..=retry_attempts {
|
for attempt in 0..=retry_attempts {
|
||||||
let result = client
|
let result = client
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
[workspace]
|
|
||||||
members = ["noisebell-common", "cache-service", "discord-bot", "rss-service"]
|
|
||||||
resolver = "2"
|
|
||||||
BIN
remote/cache-service/assets/closed.png
Normal file
BIN
remote/cache-service/assets/closed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
remote/cache-service/assets/offline.png
Normal file
BIN
remote/cache-service/assets/offline.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
remote/cache-service/assets/open.png
Normal file
BIN
remote/cache-service/assets/open.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -1,7 +1,8 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode, header};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use noisebell_common::{validate_bearer, HistoryEntry, WebhookPayload};
|
use noisebell_common::{validate_bearer, HistoryEntry, WebhookPayload};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
@ -11,6 +12,10 @@ use crate::db;
|
||||||
use crate::types::{DoorStatus, WebhookTarget};
|
use crate::types::{DoorStatus, WebhookTarget};
|
||||||
use crate::webhook;
|
use crate::webhook;
|
||||||
|
|
||||||
|
static OPEN_PNG: &[u8] = include_bytes!("../assets/open.png");
|
||||||
|
static CLOSED_PNG: &[u8] = include_bytes!("../assets/closed.png");
|
||||||
|
static OFFLINE_PNG: &[u8] = include_bytes!("../assets/offline.png");
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Arc<Mutex<rusqlite::Connection>>,
|
pub db: Arc<Mutex<rusqlite::Connection>>,
|
||||||
pub client: reqwest::Client,
|
pub client: reqwest::Client,
|
||||||
|
|
@ -18,6 +23,8 @@ pub struct AppState {
|
||||||
pub webhooks: Vec<WebhookTarget>,
|
pub webhooks: Vec<WebhookTarget>,
|
||||||
pub retry_attempts: u32,
|
pub retry_attempts: u32,
|
||||||
pub retry_base_delay_secs: u64,
|
pub retry_base_delay_secs: u64,
|
||||||
|
pub webhook_last_request: std::sync::atomic::AtomicU64,
|
||||||
|
pub webhook_tokens: std::sync::atomic::AtomicU32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unix_now() -> u64 {
|
fn unix_now() -> u64 {
|
||||||
|
|
@ -27,6 +34,9 @@ fn unix_now() -> u64 {
|
||||||
.as_secs()
|
.as_secs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WEBHOOK_RATE_LIMIT: u32 = 10;
|
||||||
|
const WEBHOOK_RATE_WINDOW_SECS: u64 = 60;
|
||||||
|
|
||||||
pub async fn post_webhook(
|
pub async fn post_webhook(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
|
@ -36,6 +46,22 @@ pub async fn post_webhook(
|
||||||
return StatusCode::UNAUTHORIZED;
|
return StatusCode::UNAUTHORIZED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simple rate limiting: reset tokens every window, reject if exhausted
|
||||||
|
let now = unix_now();
|
||||||
|
let last = state.webhook_last_request.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
if now.saturating_sub(last) >= WEBHOOK_RATE_WINDOW_SECS {
|
||||||
|
state.webhook_tokens.store(WEBHOOK_RATE_LIMIT, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
state.webhook_last_request.store(now, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
let remaining = state.webhook_tokens.fetch_update(
|
||||||
|
std::sync::atomic::Ordering::Relaxed,
|
||||||
|
std::sync::atomic::Ordering::Relaxed,
|
||||||
|
|n| if n > 0 { Some(n - 1) } else { None },
|
||||||
|
);
|
||||||
|
if remaining.is_err() {
|
||||||
|
return StatusCode::TOO_MANY_REQUESTS;
|
||||||
|
}
|
||||||
|
|
||||||
let Some(status) = DoorStatus::from_str(&body.status) else {
|
let Some(status) = DoorStatus::from_str(&body.status) else {
|
||||||
return StatusCode::BAD_REQUEST;
|
return StatusCode::BAD_REQUEST;
|
||||||
};
|
};
|
||||||
|
|
@ -126,3 +152,84 @@ pub async fn get_history(
|
||||||
|
|
||||||
Ok(Json(entries))
|
Ok(Json(entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_image_open() -> impl IntoResponse {
|
||||||
|
([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=86400")], OPEN_PNG)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_image_closed() -> impl IntoResponse {
|
||||||
|
([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=86400")], CLOSED_PNG)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_image_offline() -> impl IntoResponse {
|
||||||
|
([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=86400")], OFFLINE_PNG)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_image(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
|
let db = state.db.clone();
|
||||||
|
let status = tokio::task::spawn_blocking(move || {
|
||||||
|
let conn = db.blocking_lock();
|
||||||
|
db::get_current_status(&conn)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("db task panicked")
|
||||||
|
.unwrap_or(DoorStatus::Offline);
|
||||||
|
|
||||||
|
let image = match status {
|
||||||
|
DoorStatus::Open => OPEN_PNG,
|
||||||
|
DoorStatus::Closed => CLOSED_PNG,
|
||||||
|
DoorStatus::Offline => OFFLINE_PNG,
|
||||||
|
};
|
||||||
|
([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "public, max-age=5")], image)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_badge(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
|
let db = state.db.clone();
|
||||||
|
let status = tokio::task::spawn_blocking(move || {
|
||||||
|
let conn = db.blocking_lock();
|
||||||
|
db::get_current_status(&conn)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("db task panicked")
|
||||||
|
.unwrap_or(DoorStatus::Offline);
|
||||||
|
|
||||||
|
let (label, color) = match status {
|
||||||
|
DoorStatus::Open => ("open", "#57f287"),
|
||||||
|
DoorStatus::Closed => ("closed", "#ed4245"),
|
||||||
|
DoorStatus::Offline => ("offline", "#99aab5"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let label_width = 70u32;
|
||||||
|
let value_width = 10 + label.len() as u32 * 7;
|
||||||
|
let total_width = label_width + value_width;
|
||||||
|
let label_x = label_width as f32 / 2.0;
|
||||||
|
let value_x = label_width as f32 + value_width as f32 / 2.0;
|
||||||
|
|
||||||
|
let svg = format!(
|
||||||
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total_width}\" height=\"20\">\
|
||||||
|
<linearGradient id=\"s\" x2=\"0\" y2=\"100%\">\
|
||||||
|
<stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/>\
|
||||||
|
<stop offset=\"1\" stop-opacity=\".1\"/>\
|
||||||
|
</linearGradient>\
|
||||||
|
<clipPath id=\"r\"><rect width=\"{total_width}\" height=\"20\" rx=\"3\" fill=\"#fff\"/></clipPath>\
|
||||||
|
<g clip-path=\"url(#r)\">\
|
||||||
|
<rect width=\"{label_width}\" height=\"20\" fill=\"#555\"/>\
|
||||||
|
<rect x=\"{label_width}\" width=\"{value_width}\" height=\"20\" fill=\"{color}\"/>\
|
||||||
|
<rect width=\"{total_width}\" height=\"20\" fill=\"url(#s)\"/>\
|
||||||
|
</g>\
|
||||||
|
<g fill=\"#fff\" text-anchor=\"middle\" font-family=\"Verdana,Geneva,sans-serif\" font-size=\"11\">\
|
||||||
|
<text x=\"{label_x}\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">noisebell</text>\
|
||||||
|
<text x=\"{label_x}\" y=\"14\">noisebell</text>\
|
||||||
|
<text x=\"{value_x}\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">{label}</text>\
|
||||||
|
<text x=\"{value_x}\" y=\"14\">{label}</text>\
|
||||||
|
</g></svg>"
|
||||||
|
);
|
||||||
|
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "image/svg+xml"),
|
||||||
|
(header::CACHE_CONTROL, "no-cache, max-age=0"),
|
||||||
|
],
|
||||||
|
svg,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ pub fn init(path: &str) -> Result<Connection> {
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
status TEXT,
|
status TEXT,
|
||||||
timestamp INTEGER,
|
timestamp INTEGER,
|
||||||
last_seen INTEGER
|
last_seen INTEGER,
|
||||||
|
last_checked INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS state_log (
|
CREATE TABLE IF NOT EXISTS state_log (
|
||||||
|
|
@ -28,17 +29,29 @@ pub fn init(path: &str) -> Result<Connection> {
|
||||||
fetched_at INTEGER NOT NULL
|
fetched_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO current_state (id, status, timestamp, last_seen) VALUES (1, NULL, NULL, NULL);
|
INSERT OR IGNORE INTO current_state (id, status, timestamp, last_seen, last_checked) VALUES (1, 'offline', NULL, NULL, NULL);
|
||||||
INSERT OR IGNORE INTO pi_info (id, data, fetched_at) VALUES (1, '{}', 0);
|
INSERT OR IGNORE INTO pi_info (id, data, fetched_at) VALUES (1, '{}', 0);
|
||||||
",
|
",
|
||||||
)
|
)
|
||||||
.context("failed to initialize database schema")?;
|
.context("failed to initialize database schema")?;
|
||||||
|
|
||||||
|
// Migration: add last_checked column if missing (existing databases)
|
||||||
|
let has_last_checked: bool = conn
|
||||||
|
.prepare("SELECT last_checked FROM current_state LIMIT 1")
|
||||||
|
.is_ok();
|
||||||
|
if !has_last_checked {
|
||||||
|
conn.execute_batch("ALTER TABLE current_state ADD COLUMN last_checked INTEGER")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: convert NULL status to 'offline'
|
||||||
|
conn.execute("UPDATE current_state SET status = 'offline' WHERE status IS NULL", [])?;
|
||||||
|
|
||||||
Ok(conn)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_status(conn: &Connection) -> Result<StatusResponse> {
|
pub fn get_status(conn: &Connection) -> Result<StatusResponse> {
|
||||||
let (status, timestamp, last_seen) = conn.query_row(
|
let (status_str, timestamp, last_checked) = conn.query_row(
|
||||||
"SELECT status, timestamp, last_seen FROM current_state WHERE id = 1",
|
"SELECT status, timestamp, last_checked FROM current_state WHERE id = 1",
|
||||||
[],
|
[],
|
||||||
|row| {
|
|row| {
|
||||||
Ok((
|
Ok((
|
||||||
|
|
@ -48,10 +61,14 @@ pub fn get_status(conn: &Connection) -> Result<StatusResponse> {
|
||||||
))
|
))
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
|
let status = status_str
|
||||||
|
.as_deref()
|
||||||
|
.and_then(DoorStatus::from_str)
|
||||||
|
.unwrap_or(DoorStatus::Offline);
|
||||||
Ok(StatusResponse {
|
Ok(StatusResponse {
|
||||||
status: status.unwrap_or_else(|| "offline".to_string()),
|
status,
|
||||||
timestamp,
|
since: timestamp,
|
||||||
last_seen,
|
last_checked,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,7 +76,7 @@ pub fn update_state(conn: &Connection, status: DoorStatus, timestamp: u64, now:
|
||||||
let status_str = status.as_str();
|
let status_str = status.as_str();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE current_state SET status = ?1, timestamp = ?2, last_seen = ?3 WHERE id = 1",
|
"UPDATE current_state SET status = ?1, timestamp = ?2, last_seen = ?3 WHERE id = 1",
|
||||||
rusqlite::params![status_str, timestamp, now],
|
rusqlite::params![status_str, now, now],
|
||||||
)?;
|
)?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO state_log (status, timestamp, recorded_at) VALUES (?1, ?2, ?3)",
|
"INSERT INTO state_log (status, timestamp, recorded_at) VALUES (?1, ?2, ?3)",
|
||||||
|
|
@ -76,10 +93,18 @@ pub fn update_last_seen(conn: &Connection, now: u64) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_last_checked(conn: &Connection, now: u64) -> Result<()> {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE current_state SET last_checked = ?1 WHERE id = 1",
|
||||||
|
rusqlite::params![now],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn mark_offline(conn: &Connection, now: u64) -> Result<()> {
|
pub fn mark_offline(conn: &Connection, now: u64) -> Result<()> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE current_state SET status = NULL WHERE id = 1",
|
"UPDATE current_state SET status = 'offline', timestamp = ?1 WHERE id = 1",
|
||||||
[],
|
rusqlite::params![now],
|
||||||
)?;
|
)?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO state_log (status, timestamp, recorded_at) VALUES ('offline', ?1, ?1)",
|
"INSERT INTO state_log (status, timestamp, recorded_at) VALUES ('offline', ?1, ?1)",
|
||||||
|
|
@ -88,13 +113,16 @@ pub fn mark_offline(conn: &Connection, now: u64) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_current_status_str(conn: &Connection) -> Result<Option<String>> {
|
pub fn get_current_status(conn: &Connection) -> Result<DoorStatus> {
|
||||||
let status = conn.query_row(
|
let status_str: Option<String> = conn.query_row(
|
||||||
"SELECT status FROM current_state WHERE id = 1",
|
"SELECT status FROM current_state WHERE id = 1",
|
||||||
[],
|
[],
|
||||||
|row| row.get::<_, Option<String>>(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
Ok(status)
|
Ok(status_str
|
||||||
|
.as_deref()
|
||||||
|
.and_then(DoorStatus::from_str)
|
||||||
|
.unwrap_or(DoorStatus::Offline))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_history(conn: &Connection, limit: u32) -> Result<Vec<noisebell_common::HistoryEntry>> {
|
pub fn get_history(conn: &Connection, limit: u32) -> Result<Vec<noisebell_common::HistoryEntry>> {
|
||||||
|
|
@ -131,3 +159,105 @@ pub fn update_pi_info(conn: &Connection, data: &serde_json::Value, now: u64) ->
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_db() -> Connection {
|
||||||
|
init(":memory:").expect("failed to init test db")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initial_status_is_offline() {
|
||||||
|
let conn = test_db();
|
||||||
|
let status = get_status(&conn).unwrap();
|
||||||
|
assert_eq!(status.status, DoorStatus::Offline);
|
||||||
|
assert!(status.since.is_none());
|
||||||
|
assert!(status.last_checked.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_state_changes_status() {
|
||||||
|
let conn = test_db();
|
||||||
|
update_state(&conn, DoorStatus::Open, 1000, 1001).unwrap();
|
||||||
|
|
||||||
|
let status = get_status(&conn).unwrap();
|
||||||
|
assert_eq!(status.status, DoorStatus::Open);
|
||||||
|
assert_eq!(status.since, Some(1001));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mark_offline_sets_offline_status() {
|
||||||
|
let conn = test_db();
|
||||||
|
update_state(&conn, DoorStatus::Open, 1000, 1001).unwrap();
|
||||||
|
mark_offline(&conn, 2000).unwrap();
|
||||||
|
|
||||||
|
let status = get_status(&conn).unwrap();
|
||||||
|
assert_eq!(status.status, DoorStatus::Offline);
|
||||||
|
assert_eq!(status.since, Some(2000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_current_status_matches_get_status() {
|
||||||
|
let conn = test_db();
|
||||||
|
assert_eq!(get_current_status(&conn).unwrap(), DoorStatus::Offline);
|
||||||
|
|
||||||
|
update_state(&conn, DoorStatus::Closed, 1000, 1001).unwrap();
|
||||||
|
assert_eq!(get_current_status(&conn).unwrap(), DoorStatus::Closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_last_checked_is_readable() {
|
||||||
|
let conn = test_db();
|
||||||
|
update_last_checked(&conn, 5000).unwrap();
|
||||||
|
|
||||||
|
let status = get_status(&conn).unwrap();
|
||||||
|
assert_eq!(status.last_checked, Some(5000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_records_state_changes() {
|
||||||
|
let conn = test_db();
|
||||||
|
update_state(&conn, DoorStatus::Open, 1000, 1001).unwrap();
|
||||||
|
update_state(&conn, DoorStatus::Closed, 2000, 2001).unwrap();
|
||||||
|
mark_offline(&conn, 3000).unwrap();
|
||||||
|
|
||||||
|
let history = get_history(&conn, 10).unwrap();
|
||||||
|
assert_eq!(history.len(), 3);
|
||||||
|
assert_eq!(history[0].status, "offline");
|
||||||
|
assert_eq!(history[1].status, "closed");
|
||||||
|
assert_eq!(history[2].status, "open");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_response_serializes_correctly() {
|
||||||
|
let resp = StatusResponse {
|
||||||
|
status: DoorStatus::Open,
|
||||||
|
since: Some(1234),
|
||||||
|
last_checked: Some(5678),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&resp).unwrap();
|
||||||
|
assert_eq!(json["status"], "open");
|
||||||
|
assert_eq!(json["since"], 1234);
|
||||||
|
assert_eq!(json["last_checked"], 5678);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn null_status_migration_converts_to_offline() {
|
||||||
|
// Simulate an old database with NULL status
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute_batch("
|
||||||
|
CREATE TABLE current_state (id INTEGER PRIMARY KEY CHECK (id = 1), status TEXT, timestamp INTEGER, last_seen INTEGER);
|
||||||
|
INSERT INTO current_state (id, status, timestamp, last_seen) VALUES (1, NULL, NULL, NULL);
|
||||||
|
CREATE TABLE state_log (id INTEGER PRIMARY KEY AUTOINCREMENT, status TEXT NOT NULL, timestamp INTEGER NOT NULL, recorded_at INTEGER NOT NULL);
|
||||||
|
CREATE TABLE pi_info (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL, fetched_at INTEGER NOT NULL);
|
||||||
|
INSERT INTO pi_info (id, data, fetched_at) VALUES (1, '{}', 0);
|
||||||
|
").unwrap();
|
||||||
|
|
||||||
|
// Re-init should migrate
|
||||||
|
let conn = init(":memory:").unwrap();
|
||||||
|
let status = get_current_status(&conn).unwrap();
|
||||||
|
assert_eq!(status, DoorStatus::Offline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ use anyhow::{Context, Result};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
use std::sync::atomic::AtomicU64;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing::{info, Level};
|
use tracing::{info, Level};
|
||||||
|
|
||||||
|
|
@ -122,6 +123,8 @@ async fn main() -> Result<()> {
|
||||||
webhooks,
|
webhooks,
|
||||||
retry_attempts,
|
retry_attempts,
|
||||||
retry_base_delay_secs,
|
retry_base_delay_secs,
|
||||||
|
webhook_last_request: AtomicU64::new(0),
|
||||||
|
webhook_tokens: std::sync::atomic::AtomicU32::new(10),
|
||||||
});
|
});
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
|
|
@ -130,6 +133,11 @@ async fn main() -> Result<()> {
|
||||||
.route("/status", get(api::get_status))
|
.route("/status", get(api::get_status))
|
||||||
.route("/info", get(api::get_info))
|
.route("/info", get(api::get_info))
|
||||||
.route("/history", get(api::get_history))
|
.route("/history", get(api::get_history))
|
||||||
|
.route("/image", get(api::get_image))
|
||||||
|
.route("/image/open.png", get(api::get_image_open))
|
||||||
|
.route("/image/closed.png", get(api::get_image_closed))
|
||||||
|
.route("/image/offline.png", get(api::get_image_offline))
|
||||||
|
.route("/badge.svg", get(api::get_badge))
|
||||||
.layer(
|
.layer(
|
||||||
TraceLayer::new_for_http()
|
TraceLayer::new_for_http()
|
||||||
.make_span_with(tower_http::trace::DefaultMakeSpan::new().level(Level::INFO))
|
.make_span_with(tower_http::trace::DefaultMakeSpan::new().level(Level::INFO))
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,18 @@ pub fn spawn_status_poller(
|
||||||
let mut was_offline = false;
|
let mut was_offline = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
{
|
||||||
|
let now = unix_now();
|
||||||
|
let db = db.clone();
|
||||||
|
let _ = tokio::task::spawn_blocking(move || {
|
||||||
|
let conn = db.blocking_lock();
|
||||||
|
if let Err(e) = db::update_last_checked(&conn, now) {
|
||||||
|
error!(error = %e, "failed to update last_checked");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
let result = client
|
let result = client
|
||||||
.get(format!("{}/", config.pi_address))
|
.get(format!("{}/", config.pi_address))
|
||||||
.bearer_auth(&config.pi_api_key)
|
.bearer_auth(&config.pi_api_key)
|
||||||
|
|
@ -65,10 +77,9 @@ pub fn spawn_status_poller(
|
||||||
|
|
||||||
if let Some(ref status_str) = status_str {
|
if let Some(ref status_str) = status_str {
|
||||||
if let Some(status) = DoorStatus::from_str(status_str) {
|
if let Some(status) = DoorStatus::from_str(status_str) {
|
||||||
let current = db::get_current_status_str(&conn);
|
let current = db::get_current_status(&conn);
|
||||||
let changed = match ¤t {
|
let changed = match ¤t {
|
||||||
Ok(Some(s)) => s != status.as_str(),
|
Ok(current) => *current != status,
|
||||||
Ok(None) => true,
|
|
||||||
Err(_) => true,
|
Err(_) => true,
|
||||||
};
|
};
|
||||||
if changed {
|
if changed {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||||
pub enum DoorStatus {
|
pub enum DoorStatus {
|
||||||
Open,
|
Open,
|
||||||
Closed,
|
Closed,
|
||||||
|
Offline,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DoorStatus {
|
impl DoorStatus {
|
||||||
|
|
@ -12,6 +13,7 @@ impl DoorStatus {
|
||||||
match self {
|
match self {
|
||||||
DoorStatus::Open => "open",
|
DoorStatus::Open => "open",
|
||||||
DoorStatus::Closed => "closed",
|
DoorStatus::Closed => "closed",
|
||||||
|
DoorStatus::Offline => "offline",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,6 +21,7 @@ impl DoorStatus {
|
||||||
match s {
|
match s {
|
||||||
"open" => Some(DoorStatus::Open),
|
"open" => Some(DoorStatus::Open),
|
||||||
"closed" => Some(DoorStatus::Closed),
|
"closed" => Some(DoorStatus::Closed),
|
||||||
|
"offline" => Some(DoorStatus::Offline),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -26,9 +29,9 @@ impl DoorStatus {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct StatusResponse {
|
pub struct StatusResponse {
|
||||||
pub status: String, // "open", "closed", or "offline"
|
pub status: DoorStatus,
|
||||||
pub timestamp: Option<u64>,
|
pub since: Option<u64>, // when the current status was set
|
||||||
pub last_seen: Option<u64>,
|
pub last_checked: Option<u64>, // when the cache last attempted to poll
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -36,3 +39,34 @@ pub struct WebhookTarget {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub secret: Option<String>,
|
pub secret: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn door_status_round_trip() {
|
||||||
|
for status in [DoorStatus::Open, DoorStatus::Closed, DoorStatus::Offline] {
|
||||||
|
let s = status.as_str();
|
||||||
|
assert_eq!(DoorStatus::from_str(s), Some(status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn door_status_from_str_rejects_unknown() {
|
||||||
|
assert_eq!(DoorStatus::from_str("unknown"), None);
|
||||||
|
assert_eq!(DoorStatus::from_str(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn door_status_serde_lowercase() {
|
||||||
|
let json = serde_json::to_string(&DoorStatus::Open).unwrap();
|
||||||
|
assert_eq!(json, "\"open\"");
|
||||||
|
|
||||||
|
let deserialized: DoorStatus = serde_json::from_str("\"closed\"").unwrap();
|
||||||
|
assert_eq!(deserialized, DoorStatus::Closed);
|
||||||
|
|
||||||
|
let deserialized: DoorStatus = serde_json::from_str("\"offline\"").unwrap();
|
||||||
|
assert_eq!(deserialized, DoorStatus::Offline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ edition = "2021"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
noisebell-common = { path = "../noisebell-common" }
|
noisebell-common = { path = "../noisebell-common" }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serenity = { version = "0.12", default-features = false, features = ["client", "gateway", "model", "rustls_backend"] }
|
serenity = { version = "0.12", default-features = false, features = ["client", "gateway", "model", "rustls_backend"] }
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,17 @@ in
|
||||||
type = lib.types.path;
|
type = lib.types.path;
|
||||||
description = "Path to file containing the webhook secret.";
|
description = "Path to file containing the webhook secret.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
imageBaseUrl = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "https://noisebell.extremist.software/image";
|
||||||
|
description = "Base URL for status images used in Discord embeds.";
|
||||||
|
};
|
||||||
|
|
||||||
|
cacheUrl = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "URL of the cache service for slash commands (e.g. http://localhost:3000).";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
|
|
@ -54,6 +65,8 @@ in
|
||||||
environment = {
|
environment = {
|
||||||
NOISEBELL_DISCORD_PORT = toString cfg.port;
|
NOISEBELL_DISCORD_PORT = toString cfg.port;
|
||||||
NOISEBELL_DISCORD_CHANNEL_ID = cfg.channelId;
|
NOISEBELL_DISCORD_CHANNEL_ID = cfg.channelId;
|
||||||
|
NOISEBELL_DISCORD_IMAGE_BASE_URL = cfg.imageBaseUrl;
|
||||||
|
NOISEBELL_DISCORD_CACHE_URL = cfg.cacheUrl;
|
||||||
RUST_LOG = "info";
|
RUST_LOG = "info";
|
||||||
};
|
};
|
||||||
script = ''
|
script = ''
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,47 @@ use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use axum::extract::State;
|
use axum::extract::State as AxumState;
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use noisebell_common::{validate_bearer, WebhookPayload};
|
use noisebell_common::{validate_bearer, WebhookPayload};
|
||||||
use serenity::all::{ChannelId, Colour, CreateEmbed, CreateMessage, GatewayIntents};
|
use serenity::all::{
|
||||||
|
ChannelId, Colour, CommandInteraction, CreateCommand, CreateEmbed, CreateInteractionResponse,
|
||||||
|
CreateInteractionResponseMessage, CreateMessage, GatewayIntents, Interaction,
|
||||||
|
};
|
||||||
|
use serenity::async_trait;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing::{error, info, Level};
|
use tracing::{error, info, warn, Level};
|
||||||
|
|
||||||
struct AppState {
|
struct AppState {
|
||||||
http: Arc<serenity::all::Http>,
|
http: Arc<serenity::all::Http>,
|
||||||
channel_id: ChannelId,
|
channel_id: ChannelId,
|
||||||
webhook_secret: String,
|
webhook_secret: String,
|
||||||
|
image_base_url: String,
|
||||||
|
cache_url: String,
|
||||||
|
client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_embed(status: &str, timestamp: u64) -> CreateEmbed {
|
fn build_embed(status: &str, timestamp: u64, image_base_url: &str) -> CreateEmbed {
|
||||||
let (colour, title, description) = match status {
|
let (colour, title, description, image_file) = match status {
|
||||||
"open" => (Colour::from_rgb(87, 242, 135), "Door is open", "The door at Noisebridge is open."),
|
"open" => (Colour::from_rgb(0, 255, 0), "Noisebridge is Open!", "It's time to start hacking.", "open.png"),
|
||||||
"closed" => (Colour::from_rgb(237, 66, 69), "Door is closed", "The door at Noisebridge is closed."),
|
"closed" => (Colour::from_rgb(255, 0, 0), "Noisebridge is Closed!", "We'll see you again soon.", "closed.png"),
|
||||||
_ => (Colour::from_rgb(153, 170, 181), "Pi is offline", "The Noisebridge Pi is offline."),
|
_ => (Colour::from_rgb(153, 170, 181), "Noisebridge is Offline", "The Noisebridge Pi is not responding.", "offline.png"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let image_url = format!("{image_base_url}/{image_file}");
|
||||||
|
|
||||||
CreateEmbed::new()
|
CreateEmbed::new()
|
||||||
.title(title)
|
.title(title)
|
||||||
.description(description)
|
.description(description)
|
||||||
.colour(colour)
|
.colour(colour)
|
||||||
|
.thumbnail(image_url)
|
||||||
.timestamp(serenity::model::Timestamp::from_unix_timestamp(timestamp as i64).unwrap_or_else(|_| serenity::model::Timestamp::now()))
|
.timestamp(serenity::model::Timestamp::from_unix_timestamp(timestamp as i64).unwrap_or_else(|_| serenity::model::Timestamp::now()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_webhook(
|
async fn post_webhook(
|
||||||
State(state): State<Arc<AppState>>,
|
AxumState(state): AxumState<Arc<AppState>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(body): Json<WebhookPayload>,
|
Json(body): Json<WebhookPayload>,
|
||||||
) -> StatusCode {
|
) -> StatusCode {
|
||||||
|
|
@ -42,7 +52,7 @@ async fn post_webhook(
|
||||||
|
|
||||||
info!(status = %body.status, timestamp = body.timestamp, "received webhook");
|
info!(status = %body.status, timestamp = body.timestamp, "received webhook");
|
||||||
|
|
||||||
let embed = build_embed(&body.status, body.timestamp);
|
let embed = build_embed(&body.status, body.timestamp, &state.image_base_url);
|
||||||
let message = CreateMessage::new().embed(embed);
|
let message = CreateMessage::new().embed(embed);
|
||||||
|
|
||||||
match state.channel_id.send_message(&state.http, message).await {
|
match state.channel_id.send_message(&state.http, message).await {
|
||||||
|
|
@ -57,9 +67,227 @@ async fn post_webhook(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Handler;
|
fn unix_now() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
impl serenity::all::EventHandler for Handler {}
|
fn format_timestamp(ts: u64) -> String {
|
||||||
|
format!("<t:{}:R>", ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_status(state: &AppState, _command: &CommandInteraction) -> CreateInteractionResponse {
|
||||||
|
let url = format!("{}/status", state.cache_url);
|
||||||
|
let resp = state.client.get(&url).send().await;
|
||||||
|
|
||||||
|
let embed = match resp {
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
match resp.json::<serde_json::Value>().await {
|
||||||
|
Ok(data) => {
|
||||||
|
let status = data.get("status").and_then(|s| s.as_str()).unwrap_or("unknown");
|
||||||
|
let since = data.get("since").and_then(|t| t.as_u64());
|
||||||
|
let last_checked = data.get("last_checked").and_then(|t| t.as_u64());
|
||||||
|
|
||||||
|
let mut embed = build_embed(status, since.unwrap_or(unix_now()), &state.image_base_url);
|
||||||
|
|
||||||
|
let mut fields = Vec::new();
|
||||||
|
if let Some(ts) = since {
|
||||||
|
fields.push(("Since", format_timestamp(ts), true));
|
||||||
|
}
|
||||||
|
if let Some(ts) = last_checked {
|
||||||
|
fields.push(("Last Checked", format_timestamp(ts), true));
|
||||||
|
}
|
||||||
|
if !fields.is_empty() {
|
||||||
|
embed = embed.fields(fields);
|
||||||
|
}
|
||||||
|
embed
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "failed to parse status response");
|
||||||
|
CreateEmbed::new()
|
||||||
|
.title("Error")
|
||||||
|
.description("Failed to parse status response.")
|
||||||
|
.colour(Colour::from_rgb(255, 0, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
CreateEmbed::new()
|
||||||
|
.title("Error")
|
||||||
|
.description("Failed to reach the cache service.")
|
||||||
|
.colour(Colour::from_rgb(255, 0, 0))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
CreateInteractionResponse::Message(
|
||||||
|
CreateInteractionResponseMessage::new().embed(embed)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_info(state: &AppState, _command: &CommandInteraction) -> CreateInteractionResponse {
|
||||||
|
let url = format!("{}/info", state.cache_url);
|
||||||
|
let resp = state.client.get(&url).send().await;
|
||||||
|
|
||||||
|
let embed = match resp {
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
match resp.json::<serde_json::Value>().await {
|
||||||
|
Ok(data) => {
|
||||||
|
let mut fields = Vec::new();
|
||||||
|
|
||||||
|
if let Some(temp) = data.get("cpu_temp_celsius").and_then(|t| t.as_f64()) {
|
||||||
|
fields.push(("CPU Temp", format!("{:.1}°C", temp), true));
|
||||||
|
}
|
||||||
|
if let Some(load) = data.get("load_average").and_then(|l| l.as_array()) {
|
||||||
|
let loads: Vec<String> = load.iter().filter_map(|v| v.as_f64()).map(|v| format!("{:.2}", v)).collect();
|
||||||
|
fields.push(("Load Average", loads.join(", "), true));
|
||||||
|
}
|
||||||
|
if let Some(total) = data.get("memory_total_kb").and_then(|t| t.as_u64()) {
|
||||||
|
if let Some(avail) = data.get("memory_available_kb").and_then(|a| a.as_u64()) {
|
||||||
|
let used = total.saturating_sub(avail);
|
||||||
|
fields.push(("Memory", format!("{} / {} MB", used / 1024, total / 1024), true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(total) = data.get("disk_total_bytes").and_then(|t| t.as_u64()) {
|
||||||
|
if let Some(avail) = data.get("disk_available_bytes").and_then(|a| a.as_u64()) {
|
||||||
|
let used = total.saturating_sub(avail);
|
||||||
|
fields.push(("Disk", format!("{:.1} / {:.1} GB", used as f64 / 1e9, total as f64 / 1e9), true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(uptime) = data.get("uptime_secs").and_then(|u| u.as_u64()) {
|
||||||
|
let hours = uptime / 3600;
|
||||||
|
let mins = (uptime % 3600) / 60;
|
||||||
|
fields.push(("Uptime", format!("{}h {}m", hours, mins), true));
|
||||||
|
}
|
||||||
|
if let Some(version) = data.get("nixos_version").and_then(|v| v.as_str()) {
|
||||||
|
fields.push(("NixOS", version.to_string(), true));
|
||||||
|
}
|
||||||
|
if let Some(commit) = data.get("commit").and_then(|c| c.as_str()) {
|
||||||
|
fields.push(("Commit", commit.to_string(), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateEmbed::new()
|
||||||
|
.title("Noisebridge Pi Info")
|
||||||
|
.colour(Colour::BLUE)
|
||||||
|
.fields(fields)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "failed to parse info response");
|
||||||
|
CreateEmbed::new()
|
||||||
|
.title("Error")
|
||||||
|
.description("Failed to parse Pi info.")
|
||||||
|
.colour(Colour::from_rgb(255, 0, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
CreateEmbed::new()
|
||||||
|
.title("Error")
|
||||||
|
.description("Failed to reach the cache service.")
|
||||||
|
.colour(Colour::from_rgb(255, 0, 0))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
CreateInteractionResponse::Message(
|
||||||
|
CreateInteractionResponseMessage::new().embed(embed)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_history(state: &AppState, _command: &CommandInteraction) -> CreateInteractionResponse {
|
||||||
|
let url = format!("{}/history", state.cache_url);
|
||||||
|
let resp = state.client.get(&url).send().await;
|
||||||
|
|
||||||
|
let embed = match resp {
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
match resp.json::<Vec<serde_json::Value>>().await {
|
||||||
|
Ok(entries) => {
|
||||||
|
let lines: Vec<String> = entries.iter().take(10).map(|entry| {
|
||||||
|
let status = entry.get("status").and_then(|s| s.as_str()).unwrap_or("unknown");
|
||||||
|
let ts = entry.get("timestamp").and_then(|t| t.as_u64()).unwrap_or(0);
|
||||||
|
let emoji = match status {
|
||||||
|
"open" => "🟢",
|
||||||
|
"closed" => "🔴",
|
||||||
|
"offline" => "⚪",
|
||||||
|
_ => "❓",
|
||||||
|
};
|
||||||
|
format!("{} **{}** — {}", emoji, status, format_timestamp(ts))
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
let description = if lines.is_empty() {
|
||||||
|
"No history available.".to_string()
|
||||||
|
} else {
|
||||||
|
lines.join("\n")
|
||||||
|
};
|
||||||
|
|
||||||
|
CreateEmbed::new()
|
||||||
|
.title("Recent Door History")
|
||||||
|
.description(description)
|
||||||
|
.colour(Colour::BLUE)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "failed to parse history response");
|
||||||
|
CreateEmbed::new()
|
||||||
|
.title("Error")
|
||||||
|
.description("Failed to parse history.")
|
||||||
|
.colour(Colour::from_rgb(255, 0, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
CreateEmbed::new()
|
||||||
|
.title("Error")
|
||||||
|
.description("Failed to reach the cache service.")
|
||||||
|
.colour(Colour::from_rgb(255, 0, 0))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
CreateInteractionResponse::Message(
|
||||||
|
CreateInteractionResponseMessage::new().embed(embed)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Handler {
|
||||||
|
state: Arc<AppState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl serenity::all::EventHandler for Handler {
|
||||||
|
async fn ready(&self, ctx: serenity::all::Context, ready: serenity::model::gateway::Ready) {
|
||||||
|
info!(user = %ready.user.name, "Discord bot connected");
|
||||||
|
|
||||||
|
let commands = vec![
|
||||||
|
CreateCommand::new("status").description("Show the current door status"),
|
||||||
|
CreateCommand::new("info").description("Show Pi system information"),
|
||||||
|
CreateCommand::new("history").description("Show recent door history"),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Err(e) = serenity::all::Command::set_global_commands(&ctx.http, commands).await {
|
||||||
|
error!(error = %e, "failed to register slash commands");
|
||||||
|
} else {
|
||||||
|
info!("slash commands registered");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn interaction_create(&self, ctx: serenity::all::Context, interaction: Interaction) {
|
||||||
|
if let Interaction::Command(command) = interaction {
|
||||||
|
let response = match command.data.name.as_str() {
|
||||||
|
"status" => handle_status(&self.state, &command).await,
|
||||||
|
"info" => handle_info(&self.state, &command).await,
|
||||||
|
"history" => handle_history(&self.state, &command).await,
|
||||||
|
_ => {
|
||||||
|
CreateInteractionResponse::Message(
|
||||||
|
CreateInteractionResponseMessage::new().content("Unknown command.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = command.create_response(&ctx.http, response).await {
|
||||||
|
error!(error = %e, command = %command.data.name, "failed to respond to slash command");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
|
|
@ -83,20 +311,47 @@ async fn main() -> Result<()> {
|
||||||
.parse()
|
.parse()
|
||||||
.context("NOISEBELL_DISCORD_PORT must be a valid u16")?;
|
.context("NOISEBELL_DISCORD_PORT must be a valid u16")?;
|
||||||
|
|
||||||
|
let image_base_url = std::env::var("NOISEBELL_DISCORD_IMAGE_BASE_URL")
|
||||||
|
.unwrap_or_else(|_| "https://noisebell.extremist.software/image".into())
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let cache_url = std::env::var("NOISEBELL_DISCORD_CACHE_URL")
|
||||||
|
.context("NOISEBELL_DISCORD_CACHE_URL is required")?
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_string();
|
||||||
|
|
||||||
info!(port, channel_id, "starting noisebell-discord");
|
info!(port, channel_id, "starting noisebell-discord");
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.context("failed to build HTTP client")?;
|
||||||
|
|
||||||
let intents = GatewayIntents::empty();
|
let intents = GatewayIntents::empty();
|
||||||
let mut initial_client = serenity::Client::builder(&discord_token, intents)
|
let mut discord_client = serenity::Client::builder(&discord_token, intents)
|
||||||
.event_handler(Handler)
|
.event_handler_arc(Arc::new(Handler {
|
||||||
|
state: Arc::new(AppState {
|
||||||
|
http: Arc::new(serenity::all::Http::new(&discord_token)),
|
||||||
|
channel_id: ChannelId::new(channel_id),
|
||||||
|
webhook_secret: webhook_secret.clone(),
|
||||||
|
image_base_url: image_base_url.clone(),
|
||||||
|
cache_url: cache_url.clone(),
|
||||||
|
client: client.clone(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
.await
|
.await
|
||||||
.context("failed to create Discord client")?;
|
.context("failed to create Discord client")?;
|
||||||
|
|
||||||
let http = initial_client.http.clone();
|
let http = discord_client.http.clone();
|
||||||
|
|
||||||
let app_state = Arc::new(AppState {
|
let app_state = Arc::new(AppState {
|
||||||
http,
|
http,
|
||||||
channel_id: ChannelId::new(channel_id),
|
channel_id: ChannelId::new(channel_id),
|
||||||
webhook_secret,
|
webhook_secret,
|
||||||
|
image_base_url,
|
||||||
|
cache_url,
|
||||||
|
client,
|
||||||
});
|
});
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
|
|
@ -115,28 +370,14 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
info!(port, "webhook listener ready");
|
info!(port, "webhook listener ready");
|
||||||
|
|
||||||
// Gateway reconnect loop — the Http client for sending messages is independent
|
// Spawn gateway connection for slash commands
|
||||||
let token_for_gateway = discord_token.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = initial_client.start().await {
|
|
||||||
error!(error = %e, "Discord gateway disconnected");
|
|
||||||
}
|
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
if let Err(e) = discord_client.start().await {
|
||||||
info!("reconnecting to Discord gateway");
|
error!(error = %e, "Discord gateway disconnected");
|
||||||
match serenity::Client::builder(&token_for_gateway, GatewayIntents::empty())
|
|
||||||
.event_handler(Handler)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(mut client) => {
|
|
||||||
if let Err(e) = client.start().await {
|
|
||||||
error!(error = %e, "Discord gateway disconnected");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!(error = %e, "failed to create Discord client");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
warn!("reconnecting to Discord gateway in 5s");
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
64
remote/flake.lock
generated
64
remote/flake.lock
generated
|
|
@ -1,64 +0,0 @@
|
||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"crane": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1773115265,
|
|
||||||
"narHash": "sha256-5fDkKTYEgue2klksd52WvcXfZdY1EIlbk0QggAwpFog=",
|
|
||||||
"owner": "ipetkov",
|
|
||||||
"repo": "crane",
|
|
||||||
"rev": "27711550d109bf6236478dc9f53b9e29c1a374c5",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "ipetkov",
|
|
||||||
"repo": "crane",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1772963539,
|
|
||||||
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"crane": "crane",
|
|
||||||
"nixpkgs": "nixpkgs",
|
|
||||||
"rust-overlay": "rust-overlay"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rust-overlay": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1773115373,
|
|
||||||
"narHash": "sha256-bfK9FJFcQth6f3ydYggS5m0z2NRGF/PY6Y2XgZDJ6pg=",
|
|
||||||
"owner": "oxalica",
|
|
||||||
"repo": "rust-overlay",
|
|
||||||
"rev": "1924b4672a2b8e4aee6e6652ec2e59a8d3c5648e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "oxalica",
|
|
||||||
"repo": "rust-overlay",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
{
|
|
||||||
description = "Noisebell remote services";
|
|
||||||
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
|
||||||
crane.url = "github:ipetkov/crane";
|
|
||||||
rust-overlay = {
|
|
||||||
url = "github:oxalica/rust-overlay";
|
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs = { self, nixpkgs, crane, rust-overlay }:
|
|
||||||
let
|
|
||||||
system = "x86_64-linux";
|
|
||||||
pkgs = import nixpkgs {
|
|
||||||
inherit system;
|
|
||||||
overlays = [ rust-overlay.overlays.default ];
|
|
||||||
};
|
|
||||||
|
|
||||||
rustToolchain = pkgs.rust-bin.stable.latest.default;
|
|
||||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
|
||||||
|
|
||||||
src = craneLib.cleanCargoSource ./.;
|
|
||||||
|
|
||||||
commonArgs = {
|
|
||||||
inherit src;
|
|
||||||
pname = "noisebell";
|
|
||||||
version = "0.1.0";
|
|
||||||
strictDeps = true;
|
|
||||||
doCheck = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
|
||||||
|
|
||||||
buildMember = name: craneLib.buildPackage (commonArgs // {
|
|
||||||
inherit cargoArtifacts;
|
|
||||||
cargoExtraArgs = "-p ${name}";
|
|
||||||
});
|
|
||||||
|
|
||||||
noisebell-cache = buildMember "noisebell-cache";
|
|
||||||
noisebell-discord = buildMember "noisebell-discord";
|
|
||||||
noisebell-rss = buildMember "noisebell-rss";
|
|
||||||
in
|
|
||||||
{
|
|
||||||
packages.${system} = {
|
|
||||||
inherit noisebell-cache noisebell-discord noisebell-rss;
|
|
||||||
default = noisebell-cache;
|
|
||||||
};
|
|
||||||
|
|
||||||
nixosModules = {
|
|
||||||
cache = import ./cache-service/module.nix noisebell-cache;
|
|
||||||
discord = import ./discord-bot/module.nix noisebell-discord;
|
|
||||||
rss = import ./rss-service/module.nix noisebell-rss;
|
|
||||||
default = { imports = [
|
|
||||||
(import ./cache-service/module.nix noisebell-cache)
|
|
||||||
(import ./discord-bot/module.nix noisebell-discord)
|
|
||||||
(import ./rss-service/module.nix noisebell-rss)
|
|
||||||
]; };
|
|
||||||
};
|
|
||||||
|
|
||||||
devShells.${system}.default = craneLib.devShell {
|
|
||||||
packages = [ pkgs.rust-analyzer ];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -6,3 +6,6 @@ edition = "2021"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = "1.0"
|
||||||
|
|
|
||||||
|
|
@ -22,3 +22,44 @@ pub struct HistoryEntry {
|
||||||
pub timestamp: u64,
|
pub timestamp: u64,
|
||||||
pub recorded_at: u64,
|
pub recorded_at: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_bearer_accepts_correct_token() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("authorization", "Bearer secret123".parse().unwrap());
|
||||||
|
assert!(validate_bearer(&headers, "secret123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_bearer_rejects_wrong_token() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("authorization", "Bearer wrong".parse().unwrap());
|
||||||
|
assert!(!validate_bearer(&headers, "secret123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_bearer_rejects_missing_header() {
|
||||||
|
let headers = HeaderMap::new();
|
||||||
|
assert!(!validate_bearer(&headers, "secret123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_bearer_rejects_non_bearer_scheme() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("authorization", "Basic secret123".parse().unwrap());
|
||||||
|
assert!(!validate_bearer(&headers, "secret123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn webhook_payload_round_trips() {
|
||||||
|
let payload = WebhookPayload { status: "open".into(), timestamp: 1234567890 };
|
||||||
|
let json = serde_json::to_string(&payload).unwrap();
|
||||||
|
let deserialized: WebhookPayload = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(deserialized.status, "open");
|
||||||
|
assert_eq!(deserialized.timestamp, 1234567890);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue