This commit is contained in:
Pierre HUBERT 2024-10-05 11:11:23 +02:00
commit 59f8b59efe
24 changed files with 512 additions and 81 deletions

View File

@ -632,6 +632,7 @@ dependencies = [
"clap",
"env_logger",
"foreign-types-shared",
"fs4",
"futures",
"futures-util",
"jsonwebtoken",
@ -1021,6 +1022,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs4"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8c6b3bd49c37d2aa3f3f2220233b29a7cd23f79d1fe70e5337d25fb390793de"
dependencies = [
"rustix",
"windows-sys 0.52.0",
]
[[package]]
name = "futures"
version = "0.3.30"

View File

@ -39,3 +39,4 @@ prettytable-rs = "0.10.0"
chrono = "0.4.38"
serde_yml = "0.0.12"
bincode = "=2.0.0-rc.3"
fs4 = { version = "0.9", features = ["sync"] }

View File

@ -283,6 +283,26 @@ impl AppConfig {
}
))
}
/// Get logs directory
pub fn logs_dir(&self) -> PathBuf {
self.storage_path().join("logs")
}
/// Get the logs for a given day
pub fn log_of_day(&self, day: u64) -> PathBuf {
self.logs_dir().join(format!("{day}.log"))
}
/// Get the directory that will store OTA updates
pub fn ota_dir(&self) -> PathBuf {
self.logs_dir().join("ota")
}
/// Get the directory that will store OTA updates of a given device reference
pub fn ota_of_device(&self, dev_ref: &str) -> PathBuf {
self.ota_dir().join(dev_ref)
}
}
#[cfg(test)]

View File

@ -3,5 +3,6 @@ pub mod constants;
pub mod crypto;
pub mod devices;
pub mod energy;
pub mod logs;
pub mod server;
pub mod utils;

View File

@ -0,0 +1,17 @@
use crate::devices::device::DeviceId;
use crate::logs::severity::LogSeverity;
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct LogEntry {
/// If no device is specified then the message comes from the backend
pub device_id: Option<DeviceId>,
pub time: u64,
pub severity: LogSeverity,
pub message: String,
}
impl LogEntry {
pub fn serialize(&self) -> anyhow::Result<String> {
Ok(serde_json::to_string(self)?)
}
}

View File

@ -0,0 +1,58 @@
use crate::app_config::AppConfig;
use crate::devices::device::DeviceId;
use crate::logs::log_entry::LogEntry;
use crate::logs::severity::LogSeverity;
use crate::utils::time_utils::{curr_day_number, time_secs};
use fs4::fs_std::FileExt;
use std::fs::OpenOptions;
use std::io::{Seek, SeekFrom, Write};
pub fn save_log(
device: Option<&DeviceId>,
severity: LogSeverity,
message: String,
) -> anyhow::Result<()> {
let log_path = AppConfig::get().log_of_day(curr_day_number());
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open(&log_path)?;
file.lock_exclusive()?;
file.seek(SeekFrom::End(0))?;
file.write_all(
format!(
"{}\n",
(LogEntry {
device_id: device.cloned(),
time: time_secs(),
severity,
message,
})
.serialize()?
)
.as_bytes(),
)?;
file.flush()?;
file.unlock()?;
Ok(())
}
/// Make a logs extraction
pub fn get_logs(day: u64) -> anyhow::Result<Vec<LogEntry>> {
let file = AppConfig::get().log_of_day(day);
if !file.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(file)?
.split('\n')
.filter(|l| !l.is_empty())
.map(serde_json::from_str)
.collect::<Result<Vec<_>, _>>()?;
Ok(content)
}

View File

@ -0,0 +1,3 @@
pub mod log_entry;
pub mod logs_manager;
pub mod severity;

View File

@ -0,0 +1,7 @@
#[derive(serde::Serialize, serde::Deserialize, Copy, Clone, Debug, PartialOrd, Eq, PartialEq)]
pub enum LogSeverity {
Debug = 0,
Info,
Warn,
Error,
}

View File

@ -19,6 +19,8 @@ async fn main() -> std::io::Result<()> {
create_directory_if_missing(AppConfig::get().devices_config_path()).unwrap();
create_directory_if_missing(AppConfig::get().relays_runtime_stats_storage_path()).unwrap();
create_directory_if_missing(AppConfig::get().energy_consumption_history()).unwrap();
create_directory_if_missing(AppConfig::get().logs_dir()).unwrap();
create_directory_if_missing(AppConfig::get().ota_dir()).unwrap();
// Initialize PKI
pki::initialize_root_ca().expect("Failed to initialize Root CA!");

View File

@ -0,0 +1,22 @@
use crate::logs::logs_manager;
use crate::logs::severity::LogSeverity;
use crate::server::custom_error::HttpResult;
use crate::server::devices_api::jwt_parser::JWTRequest;
use crate::server::WebEnergyActor;
use actix_web::{web, HttpResponse};
#[derive(Debug, serde::Deserialize)]
pub struct LogRequest {
severity: LogSeverity,
message: String,
}
/// Report log message from device
pub async fn report_log(body: web::Json<JWTRequest>, actor: WebEnergyActor) -> HttpResult {
let (device, request) = body.parse_jwt::<LogRequest>(actor).await?;
log::info!("Save log message from device: {request:#?}");
logs_manager::save_log(Some(&device.id), request.severity, request.message)?;
Ok(HttpResponse::Accepted().finish())
}

View File

@ -0,0 +1,96 @@
use crate::app_config::AppConfig;
use crate::crypto::pki;
use crate::devices::device::{Device, DeviceId};
use crate::energy::energy_actor;
use crate::server::WebEnergyActor;
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
use openssl::x509::X509;
use serde::de::DeserializeOwned;
use std::collections::HashSet;
#[derive(thiserror::Error, Debug)]
pub enum JWTError {
#[error("Failed to decode JWT header")]
FailedDecodeJWT,
#[error("Missing KID in JWT!")]
MissingKidInJWT,
#[error("Sent a JWT for a device which does not exists!")]
DeviceDoesNotExists,
#[error("Sent a JWT for a device which is not validated!")]
DeviceNotValidated,
#[error("Sent a JWT using a revoked certificate!")]
RevokedCertificate,
#[error("Failed to validate JWT!")]
FailedValidateJWT,
}
#[derive(serde::Deserialize)]
pub struct JWTRequest {
pub payload: String,
}
impl JWTRequest {
pub async fn parse_jwt<E: DeserializeOwned>(
&self,
actor: WebEnergyActor,
) -> anyhow::Result<(Device, E)> {
// First, we need to extract device kid from query
let Ok(jwt_header) = jsonwebtoken::decode_header(&self.payload) else {
log::error!("Failed to decode JWT header!");
return Err(JWTError::FailedDecodeJWT.into());
};
let Some(kid) = jwt_header.kid else {
log::error!("Missing KID in JWT!");
return Err(JWTError::MissingKidInJWT.into());
};
// Fetch device information
let Some(device) = actor
.send(energy_actor::GetSingleDevice(DeviceId(kid)))
.await?
else {
log::error!("Sent a JWT for a device which does not exists!");
return Err(JWTError::DeviceDoesNotExists.into());
};
if !device.validated {
log::error!("Sent a JWT for a device which is not validated!");
return Err(JWTError::DeviceNotValidated.into());
}
// Check certificate revocation status
let cert_bytes = std::fs::read(AppConfig::get().device_cert_path(&device.id))?;
let certificate = X509::from_pem(&cert_bytes)?;
if pki::CertData::load_devices_ca()?.is_revoked(&certificate)? {
log::error!("Sent a JWT using a revoked certificate!");
return Err(JWTError::RevokedCertificate.into());
}
let (key, alg) = match DecodingKey::from_ec_pem(&cert_bytes) {
Ok(key) => (key, Algorithm::ES256),
Err(e) => {
log::warn!("Failed to decode certificate as EC certificate {e}, trying RSA...");
(
DecodingKey::from_rsa_pem(&cert_bytes)
.expect("Failed to decode RSA certificate"),
Algorithm::RS256,
)
}
};
let mut validation = Validation::new(alg);
validation.validate_exp = false;
validation.required_spec_claims = HashSet::default();
let c = match jsonwebtoken::decode::<E>(&self.payload, &key, &validation) {
Ok(c) => c,
Err(e) => {
log::error!("Failed to validate JWT! {e}");
return Err(JWTError::FailedValidateJWT.into());
}
};
Ok((device, c.claims))
}
}

View File

@ -1,15 +1,13 @@
use crate::app_config::AppConfig;
use crate::crypto::pki;
use crate::devices::device::{DeviceId, DeviceInfo};
use crate::energy::energy_actor;
use crate::energy::energy_actor::RelaySyncStatus;
use crate::server::custom_error::HttpResult;
use crate::server::devices_api::jwt_parser::JWTRequest;
use crate::server::WebEnergyActor;
use actix_web::{web, HttpResponse};
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
use openssl::nid::Nid;
use openssl::x509::{X509Req, X509};
use std::collections::HashSet;
use openssl::x509::X509Req;
#[derive(Debug, serde::Deserialize)]
pub struct EnrollRequest {
@ -129,11 +127,6 @@ pub async fn get_certificate(query: web::Query<ReqWithDevID>, actor: WebEnergyAc
.body(cert))
}
#[derive(serde::Deserialize)]
pub struct SyncRequest {
payload: String,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct Claims {
info: DeviceInfo,
@ -145,68 +138,11 @@ struct SyncResult {
}
/// Synchronize device
pub async fn sync_device(body: web::Json<SyncRequest>, actor: WebEnergyActor) -> HttpResult {
// First, we need to extract device kid from query
let Ok(jwt_header) = jsonwebtoken::decode_header(&body.payload) else {
log::error!("Failed to decode JWT header!");
return Ok(HttpResponse::BadRequest().json("Failed to decode JWT header!"));
};
let Some(kid) = jwt_header.kid else {
log::error!("Missing KID in JWT!");
return Ok(HttpResponse::BadRequest().json("Missing KID in JWT!"));
};
// Fetch device information
let Some(device) = actor
.send(energy_actor::GetSingleDevice(DeviceId(kid)))
.await?
else {
log::error!("Sent a JWT for a device which does not exists!");
return Ok(HttpResponse::NotFound().json("Sent a JWT for a device which does not exists!"));
};
if !device.validated {
log::error!("Sent a JWT for a device which is not validated!");
return Ok(HttpResponse::PreconditionFailed()
.json("Sent a JWT for a device which is not validated!"));
}
// Check certificate revocation status
let cert_bytes = std::fs::read(AppConfig::get().device_cert_path(&device.id))?;
let certificate = X509::from_pem(&cert_bytes)?;
if pki::CertData::load_devices_ca()?.is_revoked(&certificate)? {
log::error!("Sent a JWT using a revoked certificate!");
return Ok(
HttpResponse::PreconditionFailed().json("Sent a JWT using a revoked certificate!")
);
}
let (key, alg) = match DecodingKey::from_ec_pem(&cert_bytes) {
Ok(key) => (key, Algorithm::ES256),
Err(e) => {
log::warn!("Failed to decode certificate as EC certificate {e}, trying RSA...");
(
DecodingKey::from_rsa_pem(&cert_bytes).expect("Failed to decode RSA certificate"),
Algorithm::RS256,
)
}
};
let mut validation = Validation::new(alg);
validation.validate_exp = false;
validation.required_spec_claims = HashSet::default();
let c = match jsonwebtoken::decode::<Claims>(&body.payload, &key, &validation) {
Ok(c) => c,
Err(e) => {
log::error!("Failed to validate JWT! {e}");
return Ok(HttpResponse::PreconditionFailed().json("Failed to validate JWT!"));
}
};
pub async fn sync_device(body: web::Json<JWTRequest>, actor: WebEnergyActor) -> HttpResult {
let (device, claims) = body.0.parse_jwt::<Claims>(actor.clone()).await?;
let relays = actor
.send(energy_actor::SynchronizeDevice(device.id, c.claims.info))
.send(energy_actor::SynchronizeDevice(device.id, claims.info))
.await??;
Ok(HttpResponse::Ok().json(SyncResult { relays }))

View File

@ -1,2 +1,4 @@
pub mod device_logging_controller;
pub mod jwt_parser;
pub mod mgmt_controller;
pub mod utils_controller;

View File

@ -3,7 +3,7 @@ use crate::constants;
use crate::crypto::pki;
use crate::energy::energy_actor::EnergyActorAddr;
use crate::server::auth_middleware::AuthChecker;
use crate::server::devices_api::{mgmt_controller, utils_controller};
use crate::server::devices_api::{device_logging_controller, mgmt_controller, utils_controller};
use crate::server::unsecure_server::*;
use crate::server::web_api::*;
use crate::server::web_app_controller;
@ -180,6 +180,18 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
"/web_api/device/{id}",
web::delete().to(devices_controller::delete_device),
)
// OTA API
// TODO : list supported platform references
// TODO : upload a new software update
// TODO : list ota software update per platform
// TODO : download a OTA file
// TODO : delete an OTA file
// TODO : deploy an update to a device
// Logging controller API
.route(
"/web_api/logging/logs",
web::get().to(logging_controller::get_log),
)
// Relays API
.route(
"/web_api/relays/list",
@ -226,6 +238,10 @@ pub async fn secure_server(energy_actor: EnergyActorAddr) -> anyhow::Result<()>
"/devices_api/mgmt/sync",
web::post().to(mgmt_controller::sync_device),
)
.route(
"/devices_api/logging/record",
web::post().to(device_logging_controller::report_log),
)
// Web app
.route("/", web::get().to(web_app_controller::root_index))
.route(

View File

@ -0,0 +1,30 @@
use crate::devices::device::DeviceId;
use crate::logs::logs_manager;
use crate::logs::severity::LogSeverity;
use crate::server::custom_error::HttpResult;
use crate::utils::time_utils::curr_day_number;
use actix_web::{web, HttpResponse};
#[derive(serde::Deserialize)]
pub struct LogRequest {
// Day number
day: Option<u64>,
min_severity: Option<LogSeverity>,
device: Option<DeviceId>,
}
/// Get some logs
pub async fn get_log(req: web::Query<LogRequest>) -> HttpResult {
let day = req.day.unwrap_or_else(curr_day_number);
let mut logs = logs_manager::get_logs(day)?;
if let Some(min_severity) = req.min_severity {
logs.retain(|d| d.severity >= min_severity);
}
if let Some(dev_id) = &req.device {
logs.retain(|d| d.device_id.as_ref() == Some(dev_id));
}
Ok(HttpResponse::Ok().json(logs))
}

View File

@ -1,5 +1,6 @@
pub mod auth_controller;
pub mod devices_controller;
pub mod energy_controller;
pub mod logging_controller;
pub mod relays_controller;
pub mod server_controller;

View File

@ -22,6 +22,11 @@ pub fn day_number(time: u64) -> u64 {
time / (3600 * 24)
}
/// Get current day number
pub fn curr_day_number() -> u64 {
day_number(time_secs())
}
/// Get current hour, 00 => 23 (local time)
pub fn curr_hour() -> u32 {
let local: DateTime<Local> = Local::now();

View File

@ -10,6 +10,7 @@ import { DeviceRoute } from "./routes/DeviceRoute/DeviceRoute";
import { DevicesRoute } from "./routes/DevicesRoute";
import { HomeRoute } from "./routes/HomeRoute";
import { LoginRoute } from "./routes/LoginRoute";
import { LogsRoute } from "./routes/LogsRoute";
import { NotFoundRoute } from "./routes/NotFoundRoute";
import { PendingDevicesRoute } from "./routes/PendingDevicesRoute";
import { RelaysListRoute } from "./routes/RelaysListRoute";
@ -27,6 +28,7 @@ export function App() {
<Route path="devices" element={<DevicesRoute />} />
<Route path="dev/:id" element={<DeviceRoute />} />
<Route path="relays" element={<RelaysListRoute />} />
<Route path="logs" element={<LogsRoute />} />
<Route path="*" element={<NotFoundRoute />} />
</Route>
)

View File

@ -0,0 +1,29 @@
import { Dayjs } from "dayjs";
import { APIClient } from "./ApiClient";
export type LogSeverity = "Debug" | "Info" | "Warn" | "Error";
export interface LogEntry {
device_id: string;
time: number;
severity: LogSeverity;
message: string;
}
export class LogsAPI {
/**
* Request the logs from the server
*
* @param date The date that contains the requested date
*/
static async GetLogs(date: Dayjs): Promise<LogEntry[]> {
const day = Math.floor(date.unix() / (3600 * 24));
const res = await APIClient.exec({
uri: `/logging/logs?day=${day}`,
method: "GET",
});
return res.data;
}
}

View File

@ -0,0 +1,124 @@
import NavigateBeforeIcon from "@mui/icons-material/NavigateBefore";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import RefreshIcon from "@mui/icons-material/Refresh";
import {
IconButton,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tooltip,
Typography,
} from "@mui/material";
import { DatePicker } from "@mui/x-date-pickers";
import dayjs from "dayjs";
import React from "react";
import { LogEntry, LogsAPI } from "../api/LogsAPI";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { SolarEnergyRouteContainer } from "../widgets/SolarEnergyRouteContainer";
export function LogsRoute(): React.ReactElement {
const loadKey = React.useRef(1);
const [currDate, setCurrDate] = React.useState(dayjs());
const [logs, setLogs] = React.useState<LogEntry[] | undefined>();
const load = async () => {
const logs = await LogsAPI.GetLogs(currDate);
logs.reverse();
setLogs(logs);
};
const reload = () => {
setLogs(undefined);
loadKey.current += 1;
};
return (
<SolarEnergyRouteContainer
label="Logs"
actions={
<Tooltip title="Refresh logs">
<IconButton onClick={reload}>
<RefreshIcon />
</IconButton>
</Tooltip>
}
>
<div
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: "10px",
}}
>
<Tooltip title="Previous day">
<IconButton onClick={() => setCurrDate(currDate.add(-1, "day"))}>
<NavigateBeforeIcon />
</IconButton>
</Tooltip>
<DatePicker
label="Shown day"
value={currDate}
onChange={(d) => setCurrDate(d === null ? currDate : d)}
/>
<Tooltip title="Next day">
<IconButton onClick={() => setCurrDate(currDate.add(1, "day"))}>
<NavigateNextIcon />
</IconButton>
</Tooltip>
</div>
<AsyncWidget
ready={!!logs}
loadKey={loadKey.current + currDate.toString()}
errMsg="Failed to load the logs!"
load={load}
build={() => <LogsView logs={logs!} />}
/>
</SolarEnergyRouteContainer>
);
}
function LogsView(p: { logs: LogEntry[] }): React.ReactElement {
if (p.logs.length == 0) {
return (
<Typography style={{ textAlign: "center" }}>
There was no log recorded on this day.
</Typography>
);
}
return (
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} size="small" aria-label="a dense table">
<TableHead>
<TableRow>
<TableCell>Device ID</TableCell>
<TableCell>Time</TableCell>
<TableCell>Severity</TableCell>
<TableCell>Message</TableCell>
</TableRow>
</TableHead>
<TableBody>
{p.logs.map((row, id) => (
<TableRow key={id} hover>
<TableCell component="th" scope="row">
{row.device_id ?? "Backend"}
</TableCell>
<TableCell>
{new Date(row.time * 1000).toLocaleTimeString()}
</TableCell>
<TableCell>{row.severity}</TableCell>
<TableCell>{row.message}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}

View File

@ -1,4 +1,10 @@
import { mdiChip, mdiElectricSwitch, mdiHome, mdiNewBox } from "@mdi/js";
import {
mdiChip,
mdiElectricSwitch,
mdiHome,
mdiNewBox,
mdiNotebookMultiple,
} from "@mdi/js";
import Icon from "@mdi/react";
import {
List,
@ -35,6 +41,11 @@ export function SolarEnergyNavList(): React.ReactElement {
uri="/relays"
icon={<Icon path={mdiElectricSwitch} size={1} />}
/>
<NavLink
label="Logging"
uri="/logs"
icon={<Icon path={mdiNotebookMultiple} size={1} />}
/>
</List>
);
}

View File

@ -9,13 +9,20 @@ export function SolarEnergyRouteContainer(
} & PropsWithChildren
): React.ReactElement {
return (
<div style={{ margin: p.homeWidget ? "0px" : "50px" }}>
<div
style={{
margin: p.homeWidget ? "0px" : "50px",
flex: 1,
maxWidth: "1300px",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
flex: 1,
}}
>
<Typography variant={p.homeWidget ? "h6" : "h4"}>{p.label}</Typography>

View File

@ -4,6 +4,7 @@ import src.constants as constants
from cryptography.x509 import load_pem_x509_certificate
from cryptography import utils
import jwt
import json
def get_secure_origin() -> str:
@ -75,13 +76,18 @@ def device_certificate() -> str:
return res.text
def jwt_sign(data: any, dev_id: str, privkey) -> str:
"""
Generate a JWT for client request
"""
return jwt.encode(data, privkey, algorithm="RS256", headers={"kid": dev_id})
def sync_device(dev_id: str, privkey):
"""
Synchronize device with backend
"""
encoded = jwt.encode(
{"info": device_info()}, privkey, algorithm="RS256", headers={"kid": dev_id}
)
encoded = jwt_sign({"info": device_info()}, dev_id=dev_id, privkey=privkey)
res = requests.post(
f"{args.secure_origin}/devices_api/mgmt/sync",
@ -89,6 +95,19 @@ def sync_device(dev_id: str, privkey):
verify=args.root_ca_path,
)
print(encoded)
print(res)
print(res.text)
return json.loads(res.text)
def report_log(severity: str, message: str, dev_id: str, privkey):
"""
Report log message to server
"""
encoded = jwt_sign(
{"severity": severity, "message": message}, dev_id=dev_id, privkey=privkey
)
requests.post(
f"{args.secure_origin}/devices_api/logging/record",
json={"payload": encoded},
verify=args.root_ca_path,
)

View File

@ -3,6 +3,10 @@ import src.api as api
import src.pki as pki
import src.utils as utils
import os
import time
# TODO : turn off all relays
# TODO : intialize GPIO
print("Check storage")
if not os.path.isdir(args.storage):
@ -88,6 +92,13 @@ if not os.path.isfile(args.dev_crt_path):
with open(args.dev_crt_path, "w") as f:
f.write(cert)
api.report_log("Info", "Starting program main loop...", args.dev_id, args.priv_key)
print("Done. ready to operate.")
api.sync_device(args.dev_id, args.priv_key)
print("Ready to operate!.")
while True:
# TODO : implement this loop more properly
res = api.sync_device(args.dev_id, args.priv_key)
print(res)
time.sleep(5)