├── LICENSE
├── README.md
├── client
├── .gitignore
├── Cargo.toml
└── src
│ └── main.rs
├── concept.png
├── frontend
├── .gitignore
├── Cargo.toml
├── Inter.ttf
├── UbuntuMono.ttf
├── favicon.ico
├── icons
│ ├── logo.png
│ ├── logo.svg
│ ├── server.svg
│ ├── terminal.svg
│ └── timer.svg
├── index.html
├── src
│ └── main.rs
└── style.css
├── screenshot.png
└── server
├── .gitignore
├── Cargo.toml
├── config.toml.example
└── src
├── config.rs
├── cron.rs
├── error.rs
└── main.rs
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Proxtx
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # crontab_status
4 |
5 | this allows you to check if your corontab jobs actually run and if they are successful. Clients have to report the result of the job to the server. If they failed to report in time or report a non-zero exit code an alarm is triggered.
6 |
7 | # setup
8 |
9 | 1. install rust, cargo and trunk
10 | 2. On the server:
11 |
12 | - 1. cd into the "frontend" directory
13 | - 2. run `trunk build --release`
14 | - 3. cd into the "../server" directory
15 | - 4. copy "config.toml.example" to "config.toml"
16 | - 5. edit "config.toml". To create new jobs copy the first line below the job section and edit it's name (id) and optionally provide a webhook that is to be called, when the job failed. Note that the contab times currently don't support ranges. Crontab times are checked against utc
17 | - 6. run `cargo run --release`
18 |
19 | 3. On the client
20 |
21 | - 1. cd into the client directory
22 | - 2. run `cargo install --path .`
23 | - 3. then edit your crontab config
24 | - 4. prefix the jobs (after the crontab time) you added to your config.toml in the server section with `crontab_status_client --id your-job-id --password your-password --address http://server.address:port -- `
25 |
26 | 4. Now you should be good to go. Visit the port the server opened and you should see the gui.
27 |
28 | 
29 |
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | debug/
4 | target/
5 |
6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
8 | Cargo.lock
9 |
10 | # These are backup files generated by rustfmt
11 | **/*.rs.bk
12 |
13 | # MSVC Windows builds of rustc generate these, which store debugging information
14 | *.pdb
15 |
16 |
17 | # Added by cargo
18 |
19 | /target
20 |
--------------------------------------------------------------------------------
/client/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "crontab_status_client"
3 | version = "0.1.0"
4 | edition = "2021"
5 |
6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7 |
8 | [dependencies]
9 | clap = { version = "4.4.18", features = ["derive"] }
10 | gethostname = "0.4.3"
11 | reqwest = "0.11.24"
12 | serde = { version = "1.0.196", features = ["derive"] }
13 | serde_json = "1.0.113"
14 | tokio = { version = "1.35.1", features = ["full"] }
15 | url = { version = "2.5.0", features = ["serde"] }
16 |
--------------------------------------------------------------------------------
/client/src/main.rs:
--------------------------------------------------------------------------------
1 | use {
2 | clap::{Arg, Args, Command, Parser},
3 | serde::Serialize,
4 | std::str,
5 | tokio::process::Command as TokioCommand,
6 | url::Url,
7 | };
8 |
9 | #[derive(Parser, Debug)]
10 | #[command(
11 | author = "Proxtx",
12 | version = "1.0",
13 | about = "This tool is the client counterpart for Proxtx/crontab_status. It reports the status of your crontab to the server.",
14 | long_about = "Visit this page for more information: https://github.com/Proxtx/crontab_status"
15 | )]
16 | struct Cli {
17 | #[arg(short, long)]
18 | id: String,
19 |
20 | #[arg(short, long)]
21 | password: String,
22 |
23 | #[arg(short, long)]
24 | address: Url,
25 | }
26 |
27 | #[tokio::main]
28 | async fn main() {
29 | let job_command =
30 | Command::new("job_command").arg(Arg::new("job").last(true).required(true).num_args(1..));
31 | let cli = Cli::augment_args(job_command);
32 | let args = cli.get_matches();
33 | let id = args.get_one::("id").expect("Invalid type for 'ID'");
34 | let password = args
35 | .get_one::("password")
36 | .expect("Invalid type for 'password'");
37 | let command = args
38 | .get_many::("job")
39 | .expect("Invalid type for ''")
40 | .cloned()
41 | .collect::>();
42 | let mut address = args
43 | .get_one::("address")
44 | .expect("Invalid type for url! Did not provide a correct url. https://example.com/")
45 | .clone();
46 | address.set_path("/job-update");
47 |
48 | let mut request = GuardedRequest {
49 | password: password.clone(),
50 | data: ClientUpdate {
51 | job_id: id.clone(),
52 | command: command.join(" "),
53 | hostname: gethostname::gethostname()
54 | .into_string()
55 | .expect("was unable to get hostname!"),
56 | update: Update::StartingJob,
57 | },
58 | };
59 |
60 | let init_request = request.clone();
61 | let init_address = address.clone();
62 | tokio::spawn(async move {
63 | let client = reqwest::Client::new();
64 | if let Err(e) =
65 | client
66 | .post(init_address)
67 | .body(serde_json::to_string(&init_request).expect(
68 | "Was unable to send request. This is an internal error. Contact Proxtx",
69 | ))
70 | .send()
71 | .await
72 | {
73 | println!("Was unable to send request: {}", e)
74 | }
75 | });
76 |
77 | let mut command_it = command.iter();
78 | let program = command_it.next().expect("Expected a program to be run");
79 |
80 | let output = TokioCommand::new(program)
81 | .args(command_it)
82 | .output()
83 | .await
84 | .map_err(|e| println!("Fail to start program: {}", e))
85 | .expect("");
86 |
87 | let success = output.status.success();
88 | let stdout = str::from_utf8(&output.stdout).expect("Failed to get stdout of program");
89 | let stderr = str::from_utf8(&output.stderr).expect("Failed to get stderr of program");
90 |
91 | let response_update = match success {
92 | true => Update::FinishedJob(String::from(stdout)),
93 | false => Update::Error(String::from(stderr)),
94 | };
95 |
96 | request.data.update = response_update;
97 |
98 | let client = reqwest::Client::new();
99 | if let Err(e) = client
100 | .post(address)
101 | .body(
102 | serde_json::to_string(&request)
103 | .expect("Failed to send request. Internal Error. Concat Proxtx"),
104 | )
105 | .send()
106 | .await
107 | {
108 | println!("Error sending final request: {}", e)
109 | }
110 | }
111 |
112 | #[derive(Serialize, Clone)]
113 | struct GuardedRequest {
114 | password: String,
115 | data: T,
116 | }
117 |
118 | #[derive(Serialize, Debug, Clone)]
119 | pub struct ClientUpdate {
120 | job_id: String,
121 | hostname: String,
122 | command: String,
123 | update: Update,
124 | }
125 |
126 | #[derive(Serialize, Debug, Clone)]
127 | enum Update {
128 | StartingJob,
129 | FinishedJob(String),
130 | Error(String),
131 | }
132 |
--------------------------------------------------------------------------------
/concept.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Proxtx/crontab_status/6c2748c5b74ff574d9d2c3f561784e121c4b5b07/concept.png
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | debug/
4 | target/
5 |
6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
8 | Cargo.lock
9 |
10 | # These are backup files generated by rustfmt
11 | **/*.rs.bk
12 |
13 | # MSVC Windows builds of rustc generate these, which store debugging information
14 | *.pdb
15 |
16 |
17 | # Added by cargo
18 |
19 | /target
20 | dist
--------------------------------------------------------------------------------
/frontend/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "frontend"
3 | version = "0.1.0"
4 | edition = "2021"
5 |
6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7 |
8 | [dependencies]
9 | console_error_panic_hook = "0.1.7"
10 | cookie = "0.18.0"
11 | leptos = { version = "0.6.5", features = ["csr", "nightly"] }
12 | reqwest = "0.11.24"
13 | serde = { version = "1.0.196", features = ["derive"] }
14 | serde_json = "1.0.113"
15 | url = { version = "2.5.0", features = ["serde"] }
16 | web-sys = { version = "0.3.67", features = ["HtmlDocument"] }
17 |
--------------------------------------------------------------------------------
/frontend/Inter.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Proxtx/crontab_status/6c2748c5b74ff574d9d2c3f561784e121c4b5b07/frontend/Inter.ttf
--------------------------------------------------------------------------------
/frontend/UbuntuMono.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Proxtx/crontab_status/6c2748c5b74ff574d9d2c3f561784e121c4b5b07/frontend/UbuntuMono.ttf
--------------------------------------------------------------------------------
/frontend/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Proxtx/crontab_status/6c2748c5b74ff574d9d2c3f561784e121c4b5b07/frontend/favicon.ico
--------------------------------------------------------------------------------
/frontend/icons/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Proxtx/crontab_status/6c2748c5b74ff574d9d2c3f561784e121c4b5b07/frontend/icons/logo.png
--------------------------------------------------------------------------------
/frontend/icons/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
97 |
--------------------------------------------------------------------------------
/frontend/icons/server.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/icons/terminal.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/icons/timer.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Crontab Status
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/frontend/src/main.rs:
--------------------------------------------------------------------------------
1 | use {
2 | leptos::*,
3 | reqwest::StatusCode,
4 | serde::{Deserialize, Serialize},
5 | std::time::SystemTime,
6 | url::Url,
7 | web_sys::{wasm_bindgen::JsCast, HtmlInputElement},
8 | };
9 |
10 | fn main() {
11 | console_error_panic_hook::set_once();
12 |
13 | mount_to_body(|| {
14 | view! {
15 |
16 | }
17 | })
18 | }
19 |
20 | #[derive(Clone, Debug)]
21 | enum ResponseStatus {
22 | Success(T),
23 | Unauthorized,
24 | RequestError,
25 | ParseError,
26 | }
27 |
28 | impl Serializable for ResponseStatus {
29 | fn de(_bytes: &str) -> Result {
30 | Ok(ResponseStatus::Unauthorized)
31 | }
32 | fn ser(&self) -> Result {
33 | Ok("".to_string())
34 | }
35 | }
36 |
37 | #[component]
38 | fn App() -> impl IntoView {
39 | let jobs = create_resource(
40 | || {},
41 | |_| async move {
42 | let request = match GuardedRequest::new(()) {
43 | Some(v) => v,
44 | None => return ResponseStatus::Unauthorized,
45 | };
46 | let client = reqwest::Client::new();
47 | let res = match client
48 | .post(
49 | Url::parse(&leptos::window().origin())
50 | .unwrap()
51 | .join("/get-jobs")
52 | .unwrap(),
53 | )
54 | .body(serde_json::to_string(&request).unwrap())
55 | .send()
56 | .await
57 | {
58 | Ok(v) => {
59 | if let StatusCode::UNAUTHORIZED = v.status() {
60 | return ResponseStatus::Unauthorized;
61 | }
62 |
63 | match v.text().await {
64 | Ok(v) => v,
65 | Err(_) => return ResponseStatus::ParseError,
66 | }
67 | }
68 | Err(_) => {
69 | return ResponseStatus::RequestError;
70 | }
71 | };
72 |
73 | let jobs: Vec = match serde_json::from_str(&res) {
74 | Ok(v) => v,
75 | Err(_) => {
76 | return ResponseStatus::ParseError;
77 | }
78 | };
79 |
80 | ResponseStatus::Success(jobs)
81 | },
82 | );
83 |
84 | view! {{move || match jobs.get() {
85 | Some(v) => {
86 | view! {
87 | {
88 | match v {
89 | ResponseStatus::Success(v) => {
90 | view! {
91 | 
Status
92 | {
93 | v.into_iter().map(|n| view! {}).collect_view()
94 | }
95 | }.into_view()
96 | },
97 | ResponseStatus::Unauthorized => {
98 | view! {
99 |
100 | }.into_view()
101 | }
102 | _ => {
103 |
104 | view! {Status
{format!("Error: {:?}", v)}}.into_view()
105 | }
106 | }
107 | }
108 | }.into_view()
109 | }
110 | None => {
111 | view! {"Loading..."}.into_view()
112 | }
113 | }}}
114 | }
115 |
116 | #[component]
117 | fn Job(name: String) -> impl IntoView {
118 | let name_cl = name.clone();
119 | let signal = create_signal(name_cl);
120 | let job = create_resource(signal.0, |name| async move {
121 | let request = match GuardedRequest::new(name) {
122 | Some(v) => v,
123 | None => return ResponseStatus::Unauthorized,
124 | };
125 |
126 | let client = reqwest::Client::new();
127 | let res = match client
128 | .post(
129 | Url::parse(&leptos::window().origin())
130 | .unwrap()
131 | .join("/get-job")
132 | .unwrap(),
133 | )
134 | .body(serde_json::to_string(&request).unwrap())
135 | .send()
136 | .await
137 | {
138 | Ok(v) => match v.text().await {
139 | Ok(v) => v,
140 | Err(_) => return ResponseStatus::ParseError,
141 | },
142 | Err(_) => {
143 | return ResponseStatus::RequestError;
144 | }
145 | };
146 |
147 | let job: JobStatus = match serde_json::from_str(&res) {
148 | Ok(v) => v,
149 | Err(_) => {
150 | return ResponseStatus::ParseError;
151 | }
152 | };
153 |
154 | ResponseStatus::Success(job)
155 | });
156 |
157 | view! {
158 | {move || match job.get() {
159 | Some(job) => {
160 | match job {
161 | ResponseStatus::Success(v) => {
162 | let (text, class) = match &v.status {
163 | Status::ClientError => ("Failed", "statusError"),
164 | Status::Finished(_) => ("Operational", "statusFinished"),
165 | Status::WaitingForResponse(_) => ("Challenge Failed", "statusError"),
166 | Status::Running(_) => ("Running", "statusRunning"),
167 | Status::ExpectingResponse => ("Waiting", "statusWaiting"),
168 | Status::Unknown => ("Unknown", "statusUnknown"),
169 | };
170 | view! {
171 |
175 |
176 | }.into_view()
177 | }
178 | _ => {
179 | format!("Error loading job: {}", name).into_view()
180 | }
181 | }
182 | }
183 | None => {
184 | format!("Loading: {}", name).into_view()
185 | }
186 | }}
187 | }
188 | }
189 |
190 | #[component]
191 | fn JobData(job_status: JobStatus) -> impl IntoView {
192 | view! {
193 | {job_status.job.id}
194 | {
195 | if let Some(v) = job_status.hostname {
196 | view! {
197 |
198 | }
199 | }
200 | else {
201 | view! {}.into_view()
202 | }
203 | }
204 |
205 | {
206 | if let Some(v) = job_status.command {
207 | view! {
208 |
209 | }
210 | }
211 | else {
212 | view! {}.into_view()
213 | }
214 | }
215 |
216 |
217 | }
218 | }
219 |
220 | #[component]
221 | fn IconAttribute(icon_path: String, text: String) -> impl IntoView {
222 | view! {
223 |
224 | }
225 | }
226 |
227 | #[component]
228 | fn Log(log: String) -> impl IntoView {
229 | view! {
230 | {log}
231 | }
232 | }
233 |
234 | #[component]
235 | fn Login() -> impl IntoView {
236 | view! {
237 | Crontab Status
238 |
239 |
240 |
Login
241 | ().unwrap().value()); reload()} type="password" />
242 |
243 | }
244 | }
245 |
246 | #[derive(Serialize, Clone)]
247 | struct GuardedRequest {
248 | password: String,
249 | data: T,
250 | }
251 |
252 | impl GuardedRequest {
253 | pub fn new(data: T) -> Option {
254 | Some(GuardedRequest {
255 | password: get_password_cookie()?,
256 | data,
257 | })
258 | }
259 | }
260 |
261 | fn get_password_cookie() -> Option {
262 | let html_doc: web_sys::HtmlDocument = document().dyn_into().unwrap(); //document should always be cast-able to HtmlDocument
263 | let cookies = html_doc.cookie().unwrap(); //cookies are always present
264 | for cookie in cookie::Cookie::split_parse(cookies) {
265 | let cookie = cookie.unwrap(); //we got these cookies from the document
266 | if cookie.name() == "pwd" {
267 | return Some(cookie.value().to_string());
268 | }
269 | }
270 |
271 | None
272 | }
273 |
274 | fn set_password_cookie(password: String) {
275 | let html_doc: web_sys::HtmlDocument = document().dyn_into().unwrap();
276 | let cookie = cookie::Cookie::new("pwd", password);
277 | html_doc.set_cookie(&cookie.to_string()).unwrap();
278 | }
279 |
280 | fn reload() {
281 | leptos::window().location().reload().unwrap();
282 | }
283 |
284 | #[derive(Deserialize, Debug, Clone)]
285 | pub struct Job {
286 | pub execution_time: String,
287 | #[serde(default)]
288 | pub id: String,
289 | pub hook: Option,
290 | }
291 |
292 | #[derive(Clone, Deserialize, Debug)]
293 | pub struct JobStatus {
294 | job: Job,
295 | status: Status,
296 | log: Option,
297 | hostname: Option,
298 | command: Option,
299 | }
300 |
301 | #[derive(Clone, Deserialize, Debug)]
302 | pub enum Status {
303 | Running(SystemTime),
304 | Finished(SystemTime),
305 | Unknown,
306 | ExpectingResponse,
307 | WaitingForResponse(SystemTime),
308 | ClientError,
309 | }
310 |
--------------------------------------------------------------------------------
/frontend/style.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: "Mono";
3 | src: url(UbuntuMono.ttf);
4 | }
5 |
6 | @font-face {
7 | font-family: "Inter";
8 | src: url("Inter.ttf");
9 | }
10 |
11 | :root {
12 | --bg-color: #ededf4;
13 | --bg-color-2: #e6e6f0;
14 | --text: #190b28;
15 | --green: #169873;
16 | --red: #c1292e;
17 | --yellow: #fbaf00;
18 | --gap: 9px;
19 | --border-radius: 4px;
20 | }
21 |
22 | body {
23 | font-family: "Inter";
24 | background-color: var(--bg-color);
25 | padding: calc(var(--gap) * 3);
26 | margin: 0;
27 | }
28 |
29 | h1,
30 | h2,
31 | h3,
32 | h4,
33 | h5,
34 | h6 {
35 | margin: 0;
36 | }
37 |
38 | .status {
39 | display: flex;
40 | padding-top: var(--gap);
41 | flex-direction: column;
42 | gap: var(--gap);
43 | border-radius: var(--border-radius);
44 | align-items: center;
45 | overflow: hidden;
46 | position: relative;
47 | }
48 |
49 | .statusText {
50 | color: white;
51 | font-size: 10;
52 | }
53 |
54 | .statusWaiting {
55 | background-color: #facc15;
56 | }
57 |
58 | .statusError {
59 | background-color: #f87171;
60 | }
61 |
62 | .statusFinished {
63 | background-color: #4ade80;
64 | }
65 |
66 | .jobData {
67 | background-color: var(--bg-color-2);
68 | width: 100%;
69 | padding: var(--gap);
70 | display: flex;
71 | flex-direction: column;
72 | align-items: flex-start;
73 | gap: var(--gap);
74 | border-radius: var(--border-radius);
75 | box-sizing: border-box;
76 | position: relative;
77 | }
78 |
79 | @keyframes running_anim {
80 | 0% {
81 | background-color: #4ade80;
82 | }
83 | 50% {
84 | background-color: #facc15;
85 | }
86 | 100% {
87 | background-color: #4ade80;
88 | }
89 | }
90 | .statusRunning {
91 | animation: running_anim 1s linear 0s infinite forwards;
92 | }
93 |
94 | .statusUnknown {
95 | background-color: grey;
96 | }
97 |
98 | .attribute {
99 | display: flex;
100 | flex-direction: row;
101 | align-items: center;
102 | gap: var(--gap);
103 | }
104 |
105 | .attribute a {
106 | text-wrap: nowrap;
107 | overflow: hidden;
108 | }
109 |
110 | .log {
111 | width: 100%;
112 | position: relative;
113 | height: 200px;
114 | background-color: var(--text);
115 | color: #ffffff;
116 | font-family: "Mono";
117 | border-radius: var(--border-radius);
118 | padding: calc(var(--gap) / 2);
119 | overflow: auto;
120 | box-sizing: border-box;
121 | }
122 |
123 | #password_input {
124 | width: 100%;
125 | padding: var(--gap);
126 | font-size: 110%;
127 | box-sizing: border-box;
128 | border-radius: var(--border-radius);
129 | background-color: var(--bg-color);
130 | border: none;
131 | }
132 |
133 | #password_input:focus {
134 | outline: none;
135 | }
136 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Proxtx/crontab_status/6c2748c5b74ff574d9d2c3f561784e121c4b5b07/screenshot.png
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | debug/
4 | target/
5 |
6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
8 | Cargo.lock
9 |
10 | # These are backup files generated by rustfmt
11 | **/*.rs.bk
12 |
13 | # MSVC Windows builds of rustc generate these, which store debugging information
14 | *.pdb
15 |
16 |
17 | # Added by cargo
18 |
19 | /target
20 | config.toml
--------------------------------------------------------------------------------
/server/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "server"
3 | version = "0.1.0"
4 | edition = "2021"
5 |
6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7 |
8 | [dependencies]
9 | chrono = "0.4.33"
10 | reqwest = "0.11.24"
11 | rocket = { version = "0.5.0", features = ["json"] }
12 | serde = { version = "1.0.196", features = ["derive"] }
13 | tokio = { version = "1.35.1", features = ["full"] }
14 | toml = "0.8.8"
15 | url = { version = "2.5.0", features = ["serde"] }
16 |
--------------------------------------------------------------------------------
/server/config.toml.example:
--------------------------------------------------------------------------------
1 | port=8000
2 | password="my_secure_password"
3 |
4 | [jobs]
5 | backup_repos = {execution_time = "* * * * *", hook="https://example.webhook"}
--------------------------------------------------------------------------------
/server/src/config.rs:
--------------------------------------------------------------------------------
1 | use {
2 | crate::{
3 | cron::{CronExecutionTime, Job, TimeValue},
4 | error::ConfigResult,
5 | },
6 | serde::{
7 | de::{self, Visitor},
8 | Deserialize,
9 | },
10 | std::collections::HashMap,
11 | tokio::{fs::File, io::AsyncReadExt},
12 | };
13 |
14 | #[derive(Deserialize, Debug, Clone)]
15 | pub struct Config {
16 | pub password: String,
17 | pub port: u16,
18 | pub jobs: HashMap,
19 | }
20 |
21 | impl<'de> Deserialize<'de> for CronExecutionTime {
22 | fn deserialize(deserializer: D) -> Result
23 | where
24 | D: serde::Deserializer<'de>,
25 | {
26 | deserializer.deserialize_str(CronExecutionTimeVisitor)
27 | }
28 | }
29 |
30 | struct CronExecutionTimeVisitor;
31 | impl<'de> Visitor<'de> for CronExecutionTimeVisitor {
32 | type Value = CronExecutionTime;
33 |
34 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
35 | formatter.write_str("@reboot or '* * * * *' with * := valid crontab numbers")
36 | }
37 |
38 | fn visit_str(self, v: &str) -> Result
39 | where
40 | E: de::Error,
41 | {
42 | if v == "@reboot" {
43 | return Ok(CronExecutionTime::Reboot);
44 | }
45 | let mut minute = TimeValue::Every;
46 | let mut hour = TimeValue::Every;
47 | let mut day = TimeValue::Every;
48 | let mut month = TimeValue::Every;
49 | let mut weekday = TimeValue::Every;
50 |
51 | let mut str_split = v.split(' ');
52 | for i in 0..5 {
53 | let mut parsed = match str_split.next() {
54 | Some(v) => v,
55 | None => return Err(E::custom("Expected more time values")),
56 | };
57 | if i == 3 {
58 | parsed = match parsed {
59 | "jan" => "1",
60 | "feb" => "2",
61 | "mar" => "3",
62 | "apr" => "4",
63 | "may" => "5",
64 | "jun" => "6",
65 | "jul" => "7",
66 | "aug" => "8",
67 | "sep" => "9",
68 | "oct" => "10",
69 | "nov" => "11",
70 | "dez" => "12",
71 | _ => parsed,
72 | }
73 | } else if i == 4 {
74 | parsed = match parsed {
75 | "sun" => "0",
76 | "mon" => "1",
77 | "tue" => "2",
78 | "wed" => "3",
79 | "thu" => "4",
80 | "fri" => "5",
81 | "sat" => "6",
82 | _ => parsed,
83 | }
84 | }
85 | let parsed = match parsed {
86 | "*" => TimeValue::Every,
87 | v => TimeValue::Explicit(match v.parse::() {
88 | Ok(v) => v,
89 | Err(_e) => {
90 | return Err(E::custom("unable to parse u8"));
91 | }
92 | }),
93 | };
94 | match i {
95 | 0 => {
96 | if let TimeValue::Explicit(ref v) = parsed {
97 | if v > &59 {
98 | return Err(E::custom("minute int is too big"));
99 | }
100 | }
101 |
102 | minute = parsed;
103 | }
104 | 1 => {
105 | if let TimeValue::Explicit(ref v) = parsed {
106 | if v > &23 {
107 | return Err(E::custom("hour int is too big"));
108 | }
109 | }
110 | hour = parsed;
111 | }
112 | 2 => {
113 | if let TimeValue::Explicit(ref v) = parsed {
114 | if v > &31 {
115 | return Err(E::custom("day int is too big"));
116 | }
117 | if v < &1 {
118 | return Err(E::custom("day int is too small"));
119 | }
120 | }
121 | day = parsed;
122 | }
123 | 3 => {
124 | if let TimeValue::Explicit(ref v) = parsed {
125 | if v > &12 {
126 | return Err(E::custom("month int is too big"));
127 | }
128 | if v < &1 {
129 | return Err(E::custom("month int is too small"));
130 | }
131 | }
132 | month = parsed;
133 | }
134 | 4 => {
135 | if let TimeValue::Explicit(ref v) = parsed {
136 | if v > &7 {
137 | return Err(E::custom("weekday int is too big"));
138 | }
139 | }
140 | weekday = parsed;
141 | }
142 | _ => {
143 | return Err(E::custom("too many or too few time values given"));
144 | }
145 | }
146 | }
147 |
148 | if let Some(_v) = str_split.next() {
149 | return Err(E::custom("too many time values given"));
150 | }
151 |
152 | Ok(CronExecutionTime::Timing(minute, hour, day, month, weekday))
153 | }
154 | }
155 |
156 | impl Config {
157 | pub async fn load() -> ConfigResult {
158 | let mut config = String::new();
159 | File::open("config.toml")
160 | .await?
161 | .read_to_string(&mut config)
162 | .await?;
163 | let mut parsed = toml::from_str::(&config)?;
164 | let string_default = String::default();
165 | for (name, job) in parsed.jobs.iter_mut() {
166 | if job.id == string_default {
167 | job.id = name.clone();
168 | }
169 | }
170 | Ok(parsed)
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/server/src/cron.rs:
--------------------------------------------------------------------------------
1 | use {
2 | crate::error::{ConfigError, ConfigResult},
3 | chrono::{DateTime, Datelike, Timelike, Utc},
4 | serde::{Deserialize, Serialize, Serializer},
5 | std::{
6 | collections::HashMap,
7 | sync::Arc,
8 | time::{Duration, SystemTime},
9 | },
10 | tokio::{sync::RwLock, time::sleep},
11 | url::Url,
12 | };
13 |
14 | #[derive(Deserialize, Debug, Clone, Serialize)]
15 | pub struct Job {
16 | pub execution_time: CronExecutionTime,
17 | #[serde(default)]
18 | pub id: String,
19 | pub hook: Option,
20 | }
21 |
22 | #[derive(Debug, Clone)]
23 | pub enum CronExecutionTime {
24 | Reboot,
25 | Timing(TimeValue, TimeValue, TimeValue, TimeValue, TimeValue),
26 | }
27 |
28 | impl Serialize for CronExecutionTime {
29 | fn serialize(&self, serializer: S) -> Result
30 | where
31 | S: Serializer,
32 | {
33 | let res = match self {
34 | CronExecutionTime::Reboot => String::from("@reboot"),
35 | CronExecutionTime::Timing(v1, v2, v3, v4, v5) => {
36 | format!("{} {} {} {} {}", v1, v2, v3, v4, v5)
37 | }
38 | };
39 | serializer.serialize_str(&res)
40 | }
41 | }
42 |
43 | impl CronExecutionTime {
44 | pub fn matches(&self, time: DateTime) -> bool {
45 | let timing = match self {
46 | CronExecutionTime::Reboot => return false,
47 | CronExecutionTime::Timing(t1, t2, t3, t4, t5) => (t1, t2, t3, t4, t5),
48 | };
49 | let fits = (
50 | match timing.0 {
51 | TimeValue::Every => true,
52 | TimeValue::Explicit(t) => &(time.minute() as u8) == t,
53 | },
54 | match timing.1 {
55 | TimeValue::Every => true,
56 | TimeValue::Explicit(t) => &(time.hour() as u8) == t,
57 | },
58 | match timing.2 {
59 | TimeValue::Every => true,
60 | TimeValue::Explicit(t) => &(time.day() as u8) == t,
61 | },
62 | match timing.3 {
63 | TimeValue::Every => true,
64 | TimeValue::Explicit(t) => &(time.month() as u8) == t,
65 | },
66 | match timing.4 {
67 | TimeValue::Every => true,
68 | TimeValue::Explicit(t) => {
69 | let weekday = &((time.weekday() as u8) + 1);
70 | weekday == t || (weekday == &7 && t == &0)
71 | }
72 | },
73 | );
74 |
75 | fits.0 && fits.1 && fits.3 && (fits.2 || fits.4)
76 | }
77 |
78 | pub fn now(&self) -> bool {
79 | self.matches(Utc::now())
80 | }
81 | }
82 |
83 | #[derive(Debug, Clone)]
84 | pub enum TimeValue {
85 | Every,
86 | Explicit(u8),
87 | }
88 |
89 | impl std::fmt::Display for TimeValue {
90 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 | match self {
92 | TimeValue::Every => write!(f, "*"),
93 | TimeValue::Explicit(v) => write!(f, "{}", v),
94 | }
95 | }
96 | }
97 |
98 | #[derive(Clone, Serialize, Debug, PartialEq)]
99 | pub enum Status {
100 | Running(SystemTime),
101 | Finished(SystemTime),
102 | Unknown,
103 | ExpectingResponse,
104 | WaitingForResponse(SystemTime),
105 | ClientError,
106 | }
107 |
108 | #[derive(Clone, Serialize, Debug)]
109 | pub struct JobStatus {
110 | job: Job,
111 | status: Status,
112 | log: Option,
113 | hostname: Option,
114 | command: Option,
115 | }
116 |
117 | impl JobStatus {
118 | pub fn new(job: Job) -> Self {
119 | Self {
120 | job,
121 | status: Status::Unknown,
122 | log: None,
123 | hostname: None,
124 | command: None,
125 | }
126 | }
127 |
128 | pub fn update(&mut self) {
129 | match self.status {
130 | Status::Unknown => {
131 | if self.job.execution_time.now() {
132 | self.status = Status::ExpectingResponse
133 | }
134 | }
135 | Status::Finished(time) => {
136 | if self.job.execution_time.now() {
137 | let now: DateTime = chrono::DateTime::from(SystemTime::now());
138 | let happened: DateTime = chrono::DateTime::from(time);
139 | if now.minute() != happened.minute()
140 | || now.hour() != happened.hour()
141 | || now.day() != happened.day()
142 | || now.month() != happened.month()
143 | {
144 | self.status = Status::ExpectingResponse
145 | }
146 | }
147 | }
148 | Status::ExpectingResponse => {
149 | if let Some(ref url) = self.job.hook {
150 | let url = url.clone();
151 | call_hook(url)
152 | }
153 | self.status = Status::WaitingForResponse(SystemTime::now())
154 | }
155 | _ => {}
156 | }
157 | }
158 |
159 | pub fn client_update(&mut self, update: ClientUpdate) {
160 | self.hostname = Some(update.hostname);
161 | self.command = Some(update.command);
162 | match update.update {
163 | Update::StartingJob => {
164 | self.log = None;
165 | self.status = Status::Running(SystemTime::now());
166 | }
167 | Update::FinishedJob(log) => {
168 | self.log = Some(log);
169 | self.status = Status::Finished(SystemTime::now());
170 | }
171 | Update::Error(err) => {
172 | self.log = Some(err);
173 | if let Some(v) = &self.job.hook
174 | {
175 | call_hook(v.clone());
176 | }
177 | self.status = Status::ClientError;
178 | }
179 | }
180 | }
181 | }
182 |
183 | pub struct JobManager {
184 | jobs: Arc>>,
185 | }
186 |
187 | impl JobManager {
188 | pub fn new(config_jobs: HashMap) -> Self {
189 | let mut jobs = HashMap::new();
190 | for (key, job) in config_jobs {
191 | jobs.insert(key.clone(), RwLock::new(JobStatus::new(job)));
192 | }
193 |
194 | let jobs = Arc::new(jobs);
195 |
196 | let auto_update_jobs_clone = jobs.clone();
197 | tokio::spawn(async move {
198 | loop {
199 | for (_, job) in auto_update_jobs_clone.iter() {
200 | job.write().await.update();
201 | }
202 | sleep(Duration::from_secs(60)).await;
203 | }
204 | });
205 | Self { jobs }
206 | }
207 |
208 | pub async fn update(&self, update: ClientUpdate) -> ConfigResult<()> {
209 | let mut job = self
210 | .jobs
211 | .get(&update.job_id)
212 | .ok_or(ConfigError::ClientNotFound)?
213 | .write()
214 | .await;
215 | job.client_update(update);
216 |
217 | Ok(())
218 | }
219 |
220 | pub fn get_jobs(&self) -> Vec<&String> {
221 | self.jobs.keys().collect()
222 | }
223 |
224 | pub async fn get_job(&self, job: &str) -> Option {
225 | match self.jobs.get(job) {
226 | None => None,
227 | Some(v) => Some(v.read().await.clone()),
228 | }
229 | }
230 | }
231 |
232 | #[derive(Deserialize, Debug)]
233 | pub struct ClientUpdate {
234 | job_id: String,
235 | hostname: String,
236 | command: String,
237 | update: Update,
238 | }
239 |
240 | #[derive(Deserialize, Debug)]
241 | enum Update {
242 | StartingJob,
243 | FinishedJob(String),
244 | Error(String),
245 | }
246 |
247 | fn call_hook(hook: Url) {
248 | tokio::spawn(async move {
249 | if let Err(e) = reqwest::get(hook).await {
250 | println!("Error calling hook: {}", e)
251 | }
252 | });
253 | }
254 |
--------------------------------------------------------------------------------
/server/src/error.rs:
--------------------------------------------------------------------------------
1 | use std::{error::Error, fmt};
2 |
3 | pub type ConfigResult = Result;
4 |
5 | #[derive(Debug)]
6 | pub enum ConfigError {
7 | ReadFileError(std::io::Error),
8 | TomlParseError(toml::de::Error),
9 | ClientNotFound,
10 | }
11 |
12 | impl Error for ConfigError {}
13 |
14 | impl fmt::Display for ConfigError {
15 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16 | match self {
17 | ConfigError::ReadFileError(v) => {
18 | write!(f, "Unable to read config file: {}", v)
19 | }
20 | ConfigError::TomlParseError(v) => {
21 | write!(f, "Unable to parse Toml: {}", v)
22 | }
23 | ConfigError::ClientNotFound => {
24 | write!(f, "Client was not found in config!")
25 | }
26 | }
27 | }
28 | }
29 |
30 | impl From for ConfigError {
31 | fn from(value: std::io::Error) -> Self {
32 | ConfigError::ReadFileError(value)
33 | }
34 | }
35 |
36 | impl From for ConfigError {
37 | fn from(value: toml::de::Error) -> Self {
38 | ConfigError::TomlParseError(value)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/server/src/main.rs:
--------------------------------------------------------------------------------
1 | #![feature(let_chains)]
2 |
3 | mod config;
4 | mod cron;
5 | mod error;
6 |
7 | use {
8 | config::Config,
9 | cron::{ClientUpdate, JobManager, JobStatus},
10 | rocket::{fs::FileServer, http::Status, post, routes, serde::json::Json, State},
11 | serde::Deserialize,
12 | };
13 |
14 | #[rocket::launch]
15 | async fn rocket() -> _ {
16 | let config = match Config::load().await {
17 | Ok(v) => v,
18 | Err(e) => {
19 | eprintln!("Error: {}", e);
20 | std::process::exit(1);
21 | }
22 | };
23 |
24 | let manager = cron::JobManager::new(config.clone().jobs);
25 |
26 | let figment = rocket::Config::figment().merge(("port", config.port));
27 | rocket::custom(figment)
28 | .manage(manager)
29 | .manage(config)
30 | .mount("/", routes![job_update, get_job, get_jobs])
31 | .mount("/", FileServer::from("../frontend/dist/"))
32 | }
33 |
34 | #[derive(Deserialize)]
35 | struct GuardedRequest {
36 | password: String,
37 | data: T,
38 | }
39 |
40 | #[post("/job-update", data = "")]
41 | async fn job_update(
42 | config: &State,
43 | manager: &State,
44 | update: Json>,
45 | ) -> Status {
46 | let guard = update.into_inner();
47 | if guard.password != config.password {
48 | return Status::Unauthorized;
49 | }
50 | match manager.update(guard.data).await {
51 | Ok(_) => Status::Ok,
52 | Err(_e) => Status::NotFound,
53 | }
54 | }
55 |
56 | #[post("/get-jobs", data = "")]
57 | async fn get_jobs(
58 | config: &State,
59 | manager: &State,
60 | guard: Json>,
61 | ) -> Result>, Status> {
62 | if guard.password != config.password {
63 | return Err(Status::Unauthorized);
64 | }
65 | Ok(Json(manager.get_jobs().into_iter().cloned().collect()))
66 | }
67 |
68 | #[post("/get-job", data = "")]
69 | async fn get_job(
70 | config: &State,
71 | manager: &State,
72 | guard: Json>,
73 | ) -> Result>, Status> {
74 | if guard.password != config.password {
75 | return Err(Status::Unauthorized);
76 | }
77 | Ok(Json(manager.get_job(&guard.data).await))
78 | }
79 |
--------------------------------------------------------------------------------