Lot 17 : interrupteurs système tenus par la façade (LOKI_SYSTEM=rust)

La façade sert /api/internet (moteur web, serveur Crawl4AI et sa clé
jamais rendue, joignabilité avec son cache de 30 s), /api/computer
(interrupteur du navigateur, binaire trouvé, vision), /api/apikey (clé de
complétion, repli sur config.env, adresse /v1 à coller dans un client
tiers) et /api/network (écoute du moteur) quand LOKI_SYSTEM=rust
(expérimental ; le Go reste le défaut).

Restent au Go derrière son API interne (facade_system.go) : le cache de
joignabilité de ses outils web (/internal/v1/web/reach/reset), le
Chromium des outils browser_* (/internal/v1/cdp/shutdown) et le
redémarrage qui applique une clé neuve quand la façade ne pilote pas le
moteur (/internal/v1/service/action). Le relais du tunnel garde l'hôte
et X-Forwarded-Proto du client.

Correction en passant : maskAPIKey paniquait sur une clé d'un octet, et
/api/apikey tombait à chaque lecture.

Parité : parity_system_test.go tire du Go 61 appels de route avec la
base et les sondes reçues par trois faux Crawl4AI après chacun, et huit
dispositions du dossier de Playwright, rejoués par tests/system.rs ; ce
qui reste au Go vérifié de bout en bout ; TestFacadeSysteme,
TestFacadeRelaisGardeHote et TestMaskAPIKeyCourte côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 18:51:58 +00:00
1 parent 2da2efc1fd
commit a4f8bd3ec4
19 files changed
+3110 -3

No files matched your search

+3
View File
@@ -44,6 +44,8 @@ pub struct Ctx {
pub presets: bool,
/// Pilotage du moteur tenu par la façade (`LOKI_ENGINE=rust`).
pub engine_ctl: Option<std::sync::Arc<crate::enginectl::EngineCtl>>,
/// Interrupteurs système tenus par la façade (`LOKI_SYSTEM=rust`).
pub system: Option<std::sync::Arc<crate::system::System>>,
/// Jeton de l'API interne : une requête qui le porte vient du Go.
pub internal_token: String,
}
@@ -61,6 +63,7 @@ impl Ctx {
|| self.mem.is_some()
|| self.presets
|| self.engine_ctl.is_some()
|| self.system.is_some()
}
/// La requête vient du Go (jeton interne valide) : déjà authentifiée par
+1 -1
View File
@@ -343,7 +343,7 @@ pub async fn service_action(ctx: &Ctx, ctl: &EngineCtl, action: &str) -> Result<
}
/// `serviceIsActive` en conteneur : le PID du fichier vit.
fn service_is_active(ctx: &Ctx) -> bool {
pub(crate) fn service_is_active(ctx: &Ctx) -> bool {
pid_alive(crate::engine::read_pid(&ctx.engine.pid()))
}
+1
View File
@@ -36,6 +36,7 @@ pub mod serve;
pub mod settings;
pub mod store;
pub mod sysinfo;
pub mod system;
pub mod tools;
pub mod ui;
pub mod update;
+5
View File
@@ -151,6 +151,9 @@ fn serve() -> ExitCode {
// LOKI_ENGINE=rust : le pilotage du moteur (démarrage, arrêt,
// déchargement de la VRAM), en conteneur avec le superviseur Rust
// seulement — sous systemd, le Go pilote son unité.
// LOKI_SYSTEM=rust : les interrupteurs système (accès internet,
// navigateur, clé de complétion, écoute réseau du moteur).
let system_rust = held_by_facade("LOKI_SYSTEM");
let engine_rust = held_by_facade("LOKI_ENGINE")
&& match loki_rs::enginectl::usable(
std::env::var("LOKI_CONTAINER").ok().as_deref(),
@@ -178,6 +181,7 @@ fn serve() -> ExitCode {
("LOKI_MEM", mem_rust),
("LOKI_PRESETS", presets_rust),
("LOKI_ENGINE", engine_rust),
("LOKI_SYSTEM", system_rust),
];
for (var, on) in held {
if on {
@@ -280,6 +284,7 @@ fn serve() -> ExitCode {
mem: mem_rust.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home))),
presets: presets_rust,
engine_ctl,
system: system_rust.then(|| std::sync::Arc::new(loki_rs::system::System::new())),
};
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
+9
View File
@@ -82,6 +82,9 @@ pub struct Config {
/// Pilotage du moteur tenu par la façade (`LOKI_ENGINE=rust`) :
/// démarrage, arrêt, déchargement de la VRAM ; `None` : au Go.
pub engine_ctl: Option<Arc<crate::enginectl::EngineCtl>>,
/// Interrupteurs système (internet, navigateur, clé de complétion,
/// réseau) tenus par la façade (`LOKI_SYSTEM=rust`) ; `None` : au Go.
pub system: Option<Arc<crate::system::System>>,
}
struct Proxy {
@@ -117,6 +120,7 @@ pub async fn serve(
mem: cfg.mem,
presets: cfg.presets,
engine_ctl: cfg.engine_ctl,
system: cfg.system,
internal_token: cfg.internal_token.clone(),
},
});
@@ -204,6 +208,11 @@ impl Proxy {
{
return chatapi::serve(&self.ctx, chat, req, from_go).await;
}
if let Some(s) = &self.ctx.system
&& crate::system::handles(path)
{
return crate::system::serve(&self.ctx, s, req, from_go).await;
}
if let Some(e) = &self.ctx.engine_ctl
&& crate::enginectl::handles(path)
{
+664
View File
@@ -0,0 +1,664 @@
//! Interrupteurs système tenus par la façade (lot 17 de la migration,
//! `LOKI_SYSTEM=rust` ; expérimental, le Go reste le défaut) :
//!
//! - `/api/internet` — port de `handleInternet` (`web_api.go`) et de
//! `chat_internet.go` / `web_fetch_go.go` : moteur web, serveur Crawl4AI et
//! sa clé (jamais rendue), joignabilité avec son cache de 30 s ;
//! - `/api/computer` — `handleComputer`, `chromePath`, `visionEnabled` ;
//! - `/api/apikey` — `handleAPIKey` et `backend_config.go` : clé de
//! complétion, repli sur `config.env`, adresse à coller dans un client tiers
//! (`oaiBaseURL`) ;
//! - `/api/network` — `handleNetwork`, `sys_network.go` (en conteneur Linux,
//! aucun pare-feu à piloter : « inconnu »).
//!
//! Les réglages restent des clés de la base. Ce qui vit dans le processus Go
//! reste au Go : son cache de joignabilité (vidé par
//! `/internal/v1/web/reach/reset`), le Chromium des outils `browser_*`
//! (`/internal/v1/cdp/shutdown`) et le redémarrage du moteur quand la façade ne
//! le pilote pas (`/internal/v1/service/action`).
use std::time::{Duration, Instant};
use http_body_util::{BodyExt, Limited};
use hyper::body::Incoming;
use hyper::header::HOST;
use hyper::{Method, Request, Response, StatusCode};
use serde_json::{Value, json};
use crate::api::Ctx;
use crate::auth::json as send_json;
use crate::gojson::{self, Kind};
use crate::proxy::Body;
use crate::serve::{self, Cfg};
use crate::store::{BK_CONFIG, BK_STATE, Store};
/// Moteur web intégré (`engineGo`).
pub const ENGINE_GO: &str = "go";
/// Serveur Crawl4AI (`engineCrawl`).
pub const ENGINE_CRAWL: &str = "crawl4ai";
/// Durée de vie du cache de joignabilité (`reachTTL`).
pub const REACH_TTL: Duration = Duration::from_secs(30);
/// État tenu par la façade pour ce lot.
pub struct System {
/// Binaire du navigateur imposé (tests) ; `None` : [`chrome_path`].
pub chrome: Option<String>,
reach: tokio::sync::Mutex<Reach>,
client: reqwest::Client,
}
#[derive(Default)]
struct Reach {
ok: bool,
url: String,
when: Option<Instant>,
}
impl Default for System {
fn default() -> Self {
Self::new()
}
}
impl System {
pub fn new() -> Self {
crate::chat::engine::install_crypto();
System {
chrome: None,
reach: tokio::sync::Mutex::new(Reach::default()),
client: reqwest::Client::builder().build().unwrap_or_default(),
}
}
/// Le cache de joignabilité est périmé : ici, et dans le Go (outils web).
async fn reach_reset(&self, store: &Store) {
self.reach.lock().await.url.clear();
if let Err(e) = store
.internal_post("/internal/v1/web/reach/reset", &json!({}))
.await
{
eprintln!("[internet] cache de joignabilité du Go : {e}");
}
}
/// `crawlReachable` : le serveur Crawl4AI répond-il (moins de 500 sur
/// `/health` ou `/`, deux secondes en tout) ? Le moteur intégré n'a rien à
/// joindre.
async fn reachable(&self, cfg: &Cfg, key: &str) -> bool {
if web_engine(cfg) == ENGINE_GO {
return true;
}
let base = crawl4ai_url(cfg);
if base.is_empty() {
return false;
}
let mut r = self.reach.lock().await;
if base == r.url && r.when.is_some_and(|w| w.elapsed() < REACH_TTL) {
return r.ok;
}
let probe = async {
for path in ["/health", "/"] {
let mut rq = self.client.get(format!("{base}{path}"));
if !key.is_empty() {
rq = rq.header("Authorization", format!("Bearer {key}"));
}
if let Ok(resp) = rq.send().await
&& resp.status().as_u16() < 500
{
return true;
}
}
false
};
let ok = tokio::time::timeout(Duration::from_secs(2), probe)
.await
.unwrap_or(false);
*r = Reach {
ok,
url: base,
when: Some(Instant::now()),
};
ok
}
}
// --- Fonctions pures ---------------------------------------------------------
/// `crawl4aiURL` : sans slash final, vide si non configurée.
pub fn crawl4ai_url(cfg: &Cfg) -> String {
serve::trim(serve::get(cfg, "CRAWL4AI_URL"))
.trim_end_matches('/')
.to_string()
}
/// `webEngine` : le moteur choisi ; sinon Crawl4AI si une URL est déjà là
/// (installation d'avant le choix), le moteur intégré par défaut.
pub fn web_engine(cfg: &Cfg) -> &'static str {
match serve::trim(serve::get(cfg, "WEB_ENGINE"))
.to_lowercase()
.as_str()
{
ENGINE_GO => return ENGINE_GO,
ENGINE_CRAWL => return ENGINE_CRAWL,
_ => {}
}
if crawl4ai_url(cfg).is_empty() {
ENGINE_GO
} else {
ENGINE_CRAWL
}
}
/// `internetEnabled` : interrupteur armé ET moteur utilisable.
pub fn internet_enabled(cfg: &Cfg, switch_on: bool) -> bool {
if web_engine(cfg) == ENGINE_CRAWL && crawl4ai_url(cfg).is_empty() {
return false;
}
switch_on
}
/// Indice de la clé Crawl4AI : ses quatre derniers caractères.
pub fn key_hint(key: &str) -> String {
let runes: Vec<char> = key.chars().collect();
if runes.len() > 4 {
format!("…{}", runes[runes.len() - 4..].iter().collect::<String>())
} else if !key.is_empty() {
"…".into()
} else {
String::new()
}
}
/// Octets → texte comme l'encodeur JSON du Go : chaque octet invalide devient
/// U+FFFD.
fn go_lossy(mut b: &[u8]) -> String {
let mut out = String::new();
loop {
match std::str::from_utf8(b) {
Ok(s) => {
out.push_str(s);
return out;
}
Err(e) => {
let (ok, rest) = b.split_at(e.valid_up_to());
out.push_str(std::str::from_utf8(ok).unwrap_or_default());
let bad = e.error_len().unwrap_or(rest.len());
out.extend(std::iter::repeat_n('\u{FFFD}', bad));
b = &rest[bad..];
}
}
}
}
/// `maskAPIKey` : préfixe « sk-loki-? » et quatre derniers octets ; une clé de
/// treize octets au plus ne montre que les deux derniers, une clé d'un ou deux
/// octets rien.
pub fn mask_api_key(k: &str) -> String {
let b = k.as_bytes();
match b.len() {
0 => String::new(),
1 | 2 => "…".into(),
n if n <= 13 => format!("…{}", go_lossy(&b[n - 2..])),
n => format!("{}…{}", go_lossy(&b[..9]), go_lossy(&b[n - 4..])),
}
}
/// `genAPIKey`.
pub fn gen_api_key() -> String {
let mut b = [0u8; 24];
let _ = getrandom::fill(&mut b);
format!("sk-loki-{}", hex::encode(b))
}
/// `visionEnabled` : un projecteur multimodal est-il configuré ?
pub fn vision_enabled(cfg: &Cfg) -> bool {
if serve::is_external(cfg) {
return serve::trim(serve::get(cfg, "EXTERNAL_VISION")) == "1";
}
if serve::is_strata(cfg) && serve::get(cfg, "STRATA_VISION") != "0" {
return true;
}
!serve::trim(serve::get(cfg, "MMPROJ")).is_empty()
}
/// `playwrightChromium` : le Chromium déposé par Playwright, le plus récent.
pub fn playwright_chromium(browsers_env: Option<&str>, home: Option<&str>) -> String {
let mut roots = Vec::new();
if let Some(p) = browsers_env
.map(str::trim)
.filter(|p| !p.is_empty() && *p != "0")
{
roots.push(p.to_string());
}
roots.push("/opt/pw-browsers".into());
if let Some(h) = home.filter(|h| !h.is_empty()) {
roots.push(crate::paths::go_join(&[h, ".cache", "ms-playwright"]));
}
for root in roots {
let Ok(rd) = std::fs::read_dir(&root) else {
continue;
};
let mut dirs: Vec<String> = rd
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| {
n.strip_prefix("chromium")
.is_some_and(|rest| rest.contains('-'))
})
.map(|n| crate::paths::go_join(&[&root, &n]))
.collect();
dirs.sort();
for d in dirs.iter().rev() {
for rel in [
["chrome-linux", "chrome"],
["chrome-linux", "headless_shell"],
] {
let p = crate::paths::go_join(&[d, rel[0], rel[1]]);
if std::fs::metadata(&p).is_ok_and(|m| !m.is_dir()) {
return p;
}
}
}
}
String::new()
}
/// `chromePath` : `LOKI_CHROME`, le Chromium de Playwright, puis le PATH.
pub fn chrome_path() -> String {
let env = |k: &str| std::env::var(k).ok();
if let Some(p) = env("LOKI_CHROME")
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
{
return p;
}
let p = playwright_chromium(
env("PLAYWRIGHT_BROWSERS_PATH").as_deref(),
env("HOME").as_deref(),
);
if !p.is_empty() {
return p;
}
for n in [
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
"microsoft-edge",
"chrome",
] {
if let Some(p) = crate::paths::look_path(n) {
return p;
}
}
String::new()
}
/// `engineHost`.
pub fn engine_host(cfg: &Cfg) -> String {
let h = serve::trim(serve::get(cfg, "HOST"));
if h.is_empty() {
"0.0.0.0".into()
} else {
h.into()
}
}
/// `lanExposed`.
pub fn lan_exposed(cfg: &Cfg) -> bool {
let h = engine_host(cfg);
h != "127.0.0.1" && h != "::1" && !h.eq_ignore_ascii_case("localhost")
}
/// `localIP` : l'adresse par laquelle cette machine sort sur le réseau.
pub fn local_ip() -> String {
let s = match std::net::UdpSocket::bind("0.0.0.0:0") {
Ok(s) => s,
Err(_) => return "localhost".into(),
};
if s.connect("8.8.8.8:80").is_err() {
return "localhost".into();
}
s.local_addr()
.map(|a| a.ip().to_string())
.unwrap_or_else(|_| "localhost".into())
}
/// `networkStatus` (aucun pare-feu piloté hors Windows).
pub fn network_status(cfg: &Cfg) -> Value {
let port = crate::engstatus::llm_port(cfg);
json!({
"exposed": lan_exposed(cfg),
"host": engine_host(cfg),
"port": port,
"url": format!("http://{}:{port}/v1", local_ip()),
"firewall": "inconnu",
"hint": "",
})
}
/// `requestBase` : « schéma://hôte » de la requête (la façade ne termine pas
/// le TLS : le schéma ne vient que de X-Forwarded-Proto).
pub fn request_base(headers: &hyper::HeaderMap, uri: &hyper::Uri) -> String {
let mut scheme = "http".to_string();
if let Some(fwd) = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.filter(|v| !v.is_empty())
{
let first = fwd.split(',').next().unwrap_or("").trim().to_lowercase();
if first == "http" || first == "https" {
scheme = first;
}
}
let host = headers
.get(HOST)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
.or_else(|| uri.authority().map(|a| a.to_string()))
.filter(|h| !h.is_empty())
.unwrap_or_else(|| "localhost".into());
format!("{scheme}://{host}")
}
// --- Routes ------------------------------------------------------------------
/// Les routes servies par ce module.
pub fn handles(path: &str) -> bool {
matches!(
path,
"/api/internet" | "/api/computer" | "/api/apikey" | "/api/network"
)
}
/// `putStr` : une chaîne vide efface la clé.
async fn put_str(store: &Store, bucket: &str, key: &str, val: &str) -> Result<(), String> {
if val.is_empty() {
store.del(bucket, key).await
} else {
store.put(bucket, key, val.as_bytes().to_vec()).await
}
}
/// `putBool`.
async fn put_bool(store: &Store, key: &str, on: bool) -> Result<(), String> {
put_str(store, BK_STATE, key, if on { "1" } else { "" }).await
}
async fn get_bool(store: &Store, key: &str) -> bool {
store.get_str(BK_STATE, key).await == "1"
}
fn fail(code: StatusCode, e: impl Into<String>) -> Response<Body> {
send_json(code, &json!({ "ok": false, "error": e.into() }))
}
/// Le verrou de l'optimiseur (au Go) : `Some(réponse 409)` s'il tient le
/// moteur.
async fn tune_deny(store: &Store) -> Option<Response<Body>> {
let v = store
.internal_json(Method::GET, "/internal/v1/tune/guard", None)
.await
.ok()?;
if v.get("busy").and_then(Value::as_bool) == Some(true) {
return Some(send_json(
StatusCode::CONFLICT,
&json!({ "ok": false, "tuning": true, "error": v.get("error").and_then(Value::as_str).unwrap_or("") }),
));
}
None
}
/// La clé ne s'applique qu'au redémarrage du moteur : relancé s'il tourne,
/// par la façade si elle le pilote, sinon par le Go.
async fn restart_if_active(ctx: &Ctx) {
if let Some(ctl) = &ctx.engine_ctl {
if crate::enginectl::service_is_active(ctx) {
let _ = crate::enginectl::service_action(ctx, ctl, "restart").await;
}
return;
}
if let Err(e) = ctx
.store
.internal_post(
"/internal/v1/service/action",
&json!({ "action": "restart", "if_active": true }),
)
.await
{
eprintln!("[clé] redémarrage du moteur : {e}");
}
}
const INTERNET_REQ_TYPE: &str = r#"struct { Enabled *bool "json:\"enabled\""; URL *string "json:\"url\""; Key *string "json:\"key\""; Engine *string "json:\"engine\"" }"#;
const COMPUTER_REQ_TYPE: &str = r#"struct { On bool "json:\"on\"" }"#;
const APIKEY_REQ_TYPE: &str =
r#"struct { Action string "json:\"action\""; Key string "json:\"key\"" }"#;
const NETWORK_REQ_TYPE: &str = r#"struct { Exposed *bool "json:\"exposed\"" }"#;
/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui.
pub async fn serve(
ctx: &Ctx,
sys: &System,
req: Request<Incoming>,
from_go: bool,
) -> Response<Body> {
let (parts, body) = req.into_parts();
if !from_go && let Err(refus) = ctx.guard(&parts.headers).await {
return *refus;
}
let post = parts.method == Method::POST;
let raw = if post {
Limited::new(body, 1 << 20)
.collect()
.await
.map(|b| b.to_bytes().to_vec())
.unwrap_or_default()
} else {
Vec::new()
};
let store = &ctx.store;
match parts.uri.path() {
"/api/internet" => {
if post {
let d = gojson::decode_struct(
&raw,
INTERNET_REQ_TYPE,
&[
("enabled", Kind::OptBool),
("url", Kind::OptStr),
("key", Kind::OptStr),
("engine", Kind::OptStr),
],
);
if let Some(engine) = d.opt_str("engine") {
let e = engine.trim().to_lowercase();
if e != ENGINE_GO && e != ENGINE_CRAWL {
return fail(
StatusCode::BAD_REQUEST,
format!("moteur web inconnu : {}", gojson::quote(&e)),
);
}
if let Err(err) = store.put(BK_CONFIG, "WEB_ENGINE", e.into_bytes()).await {
return fail(StatusCode::BAD_REQUEST, err);
}
sys.reach_reset(store).await;
}
if let Some(key) = d.opt_str("key") {
// Rien ne traîne dans la configuration.
let _ = store.del(BK_CONFIG, "CRAWL4AI_KEY").await;
if let Err(err) = put_str(store, BK_STATE, "crawl_key", key.trim()).await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, err);
}
sys.reach_reset(store).await;
}
if let Some(u) = d.opt_str("url") {
let u = u.trim().trim_end_matches('/');
if let Err(err) = put_str(store, BK_CONFIG, "CRAWL4AI_URL", u).await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, err);
}
sys.reach_reset(store).await;
}
if let Some(on) = d.opt_bool("enabled")
&& let Err(err) = put_bool(store, "internet", on).await
{
return fail(StatusCode::INTERNAL_SERVER_ERROR, err);
}
}
let cfg = store.config().await;
let key = store.get_str(BK_STATE, "crawl_key").await;
let switch_on = get_bool(store, "internet").await;
send_json(
StatusCode::OK,
&json!({
"ok": true,
"enabled": internet_enabled(&cfg, switch_on),
"engine": web_engine(&cfg),
"url": crawl4ai_url(&cfg),
"reachable": sys.reachable(&cfg, &key).await,
"key_set": !key.is_empty(),
"key_hint": key_hint(&key),
}),
)
}
"/api/computer" => {
if post {
let on = gojson::decode_struct(&raw, COMPUTER_REQ_TYPE, &[("on", Kind::Bool)])
.bool("on");
if let Err(e) = put_bool(store, "computer", on).await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
// Couper l'interrupteur ferme le navigateur (au Go, qui le
// pilote pour les outils browser_*).
if !on
&& let Err(e) = store
.internal_post("/internal/v1/cdp/shutdown", &json!({}))
.await
{
eprintln!("[navigateur] fermeture demandée au Go : {e}");
}
}
let bin = match &sys.chrome {
Some(c) => c.clone(),
None => tokio::task::spawn_blocking(chrome_path)
.await
.unwrap_or_default(),
};
let cfg = store.config().await;
send_json(
StatusCode::OK,
&json!({
"ok": true,
"enabled": get_bool(store, "computer").await,
"browser": bin,
"browser_ok": !bin.is_empty(),
"vision": vision_enabled(&cfg),
}),
)
}
"/api/apikey" => {
if post {
if let Some(r) = tune_deny(store).await {
return r;
}
let d = gojson::decode_struct(
&raw,
APIKEY_REQ_TYPE,
&[("action", Kind::Str), ("key", Kind::Str)],
);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let key = match d.str("action").as_str() {
"generate" => gen_api_key(),
"set" => d.str("key").trim().to_string(),
"clear" => String::new(),
_ => return fail(StatusCode::BAD_REQUEST, "action inconnue"),
};
// Aucune ambiguïté avec une valeur résiduelle de config.env.
let _ = store.del(BK_CONFIG, "API_KEY").await;
if let Err(e) = put_str(store, BK_STATE, "api_key", &key).await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
restart_if_active(ctx).await;
}
// `effectiveAPIKeyErr` : la clé en base, sinon celle de config.env.
let mut k = store
.get(BK_STATE, "api_key")
.await
.ok()
.flatten()
.map(|b| go_lossy(&b))
.unwrap_or_default();
if k.is_empty() {
k = serve::trim(serve::get(&store.config().await, "API_KEY")).to_string();
}
let public = store.get_str(BK_STATE, "public_url").await;
let base = if public.is_empty() {
request_base(&parts.headers, &parts.uri)
} else {
public.clone()
};
send_json(
StatusCode::OK,
&json!({
"ok": true,
"set": !k.is_empty(),
"masked": mask_api_key(&k),
"key": k,
"url": format!("{base}/v1"),
"public_url": public,
}),
)
}
"/api/network" => {
if post {
let d =
gojson::decode_struct(&raw, NETWORK_REQ_TYPE, &[("exposed", Kind::OptBool)]);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
if let Some(on) = d.opt_bool("exposed") {
let host = if on { "0.0.0.0" } else { "127.0.0.1" };
if let Err(e) = store.put(BK_CONFIG, "HOST", host.as_bytes().to_vec()).await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
let cfg = store.config().await;
let st = tokio::task::spawn_blocking(move || network_status(&cfg))
.await
.unwrap_or_default();
return send_json(StatusCode::OK, &json!({ "ok": true, "status": st }));
}
}
let cfg = store.config().await;
let st = tokio::task::spawn_blocking(move || network_status(&cfg))
.await
.unwrap_or_default();
send_json(StatusCode::OK, &json!({ "ok": true, "status": st }))
}
_ => send_json(StatusCode::NOT_FOUND, &json!({ "error": "route inconnue" })),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn octets_invalides_comme_go() {
assert_eq!(go_lossy(b"ab"), "ab");
assert_eq!(go_lossy(&[0x61, 0xC3]), "a\u{FFFD}");
assert_eq!(go_lossy(&[0xA9, 0x62]), "\u{FFFD}b");
assert_eq!(go_lossy(&[0xE2, 0x82, 0x41]), "\u{FFFD}\u{FFFD}A");
}
#[test]
fn masque_sur_les_octets() {
assert_eq!(mask_api_key("k"), "…");
assert_eq!(mask_api_key("abc"), "…bc");
assert_eq!(mask_api_key("sk-loki-0123456789abcdef"), "sk-loki-0…cdef");
// Coupé au milieu d'un caractère : U+FFFD, comme l'encodeur du Go.
assert_eq!(mask_api_key("abcdefghijé0"), "…\u{FFFD}0");
assert_eq!(mask_api_key("abcdefgh€xyz0123"), "abcdefgh\u{FFFD}…0123");
}
}
+30
View File
@@ -84,6 +84,12 @@ pub struct FakeGo {
pub prestarts: Mutex<u32>,
/// /internal/v1/dictate/stop reçus.
pub dictate_stops: Mutex<u32>,
/// /internal/v1/web/reach/reset reçus.
pub reach_resets: Mutex<u32>,
/// /internal/v1/cdp/shutdown reçus.
pub cdp_shutdowns: Mutex<u32>,
/// /internal/v1/service/action reçus : (action, if_active).
pub service_actions: Mutex<Vec<(String, bool)>>,
/// Comportement de /internal/v1/crypt/* (préfixe « SCELLE: » par défaut).
pub crypt_mode: Mutex<CryptMode>,
/// Réponse de /internal/v1/conv/workspace (None : 404).
@@ -99,6 +105,11 @@ impl FakeGo {
.or_default()
.insert(key.into(), val.as_bytes().to_vec());
}
pub fn del(&self, bucket: &str, key: &str) {
if let Some(b) = self.store.lock().unwrap().get_mut(bucket) {
b.remove(key);
}
}
pub fn get(&self, bucket: &str, key: &str) -> Option<String> {
let s = self.store.lock().unwrap();
s.get(bucket)?
@@ -351,6 +362,23 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
200,
serde_json::json!({ "generating": *st.generating.lock().unwrap() }).to_string(),
),
"/internal/v1/web/reach/reset" => {
*st.reach_resets.lock().unwrap() += 1;
resp(204, "")
}
"/internal/v1/cdp/shutdown" => {
*st.cdp_shutdowns.lock().unwrap() += 1;
resp(204, "")
}
"/internal/v1/service/action" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
st.service_actions.lock().unwrap().push((
v["action"].as_str().unwrap_or("").to_string(),
v["if_active"].as_bool().unwrap_or(false),
));
resp(200, r#"{"ran":true,"error":""}"#)
}
"/internal/v1/dictate/stop" => {
*st.dictate_stops.lock().unwrap() += 1;
resp(204, "")
@@ -550,6 +578,7 @@ pub struct Opts {
pub mem: Option<Arc<loki_rs::memory::Memory>>,
pub presets: bool,
pub engine_ctl: Option<Arc<loki_rs::enginectl::EngineCtl>>,
pub system: Option<Arc<loki_rs::system::System>>,
}
/// Comme [`start`], avec la conversation tenue par la façade
@@ -647,6 +676,7 @@ pub async fn start_with(
mem: opts.mem,
presets: opts.presets,
engine_ctl: opts.engine_ctl,
system: opts.system,
};
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
let _ = rx.await;
+1
View File
@@ -102,6 +102,7 @@ async fn banc(cle: Option<&str>) -> Banc {
mem: None,
presets: false,
engine_ctl: None,
system: None,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
File diff suppressed because it is too large. Load diff
+1
View File
@@ -108,6 +108,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
mem: None,
presets: false,
engine_ctl: None,
system: None,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
+369
View File
@@ -0,0 +1,369 @@
//! Lot 17 : interrupteurs système tenus par la façade (`LOKI_SYSTEM=rust`).
//!
//! Le journal tiré du Go (`internal/loki/parity_system_test.go` →
//! `tests/parity_system/system.json`) est rejoué par une seule façade : accès
//! internet et Crawl4AI (en-têtes reçus par les faux serveurs et cache de
//! joignabilité compris), navigateur et vision, clé de complétion, écoute
//! réseau, avec après chaque appel les clés touchées en base ; puis le
//! Chromium de Playwright. Ce qui reste au Go (cache de ses outils web,
//! Chromium piloté, redémarrage du moteur) est vérifié de bout en bout contre
//! le faux Go.
mod common;
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use bytes::Bytes;
use common::fakego;
use http_body_util::{BodyExt, Full};
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use loki_rs::system::{self as sy, System};
use serde_json::{Value, json};
use tokio::net::TcpListener;
fn fixture() -> Value {
serde_json::from_str(include_str!("parity_system/system.json")).expect("journal")
}
fn s(v: &Value) -> &str {
v.as_str().unwrap()
}
const KEYS: &[&str] = &[
"config/WEB_ENGINE",
"config/CRAWL4AI_URL",
"config/CRAWL4AI_KEY",
"config/API_KEY",
"config/HOST",
"state/internet",
"state/computer",
"state/crawl_key",
"state/api_key",
"state/public_url",
];
/// Un faux Crawl4AI : répond `code` partout, note « chemin Authorization ».
async fn fake_crawl(code: u16, log: Arc<Mutex<Vec<String>>>) -> String {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((st, _)) = l.accept().await {
let log = log.clone();
tokio::spawn(async move {
let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
let log = log.clone();
async move {
let auth = req
.headers()
.get("authorization")
.map(|v| String::from_utf8_lossy(v.as_bytes()).into_owned())
.unwrap_or_default();
log.lock()
.unwrap()
.push(format!("{} {auth}", req.uri().path()).trim().to_string());
let mut r = Response::new(Full::new(Bytes::new()));
*r.status_mut() = hyper::StatusCode::from_u16(code).unwrap();
Ok::<_, Infallible>(r)
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(st), svc)
.await;
});
}
});
format!("http://{addr}")
}
async fn call(
facade: SocketAddr,
method: &str,
path: &str,
body: &str,
headers: &[(String, String)],
) -> (u16, String) {
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
let mut req = Request::builder()
.method(method)
.uri(format!("http://{facade}{path}"))
.header("host", "loki.local:8090");
for (k, v) in headers {
req = req.header(k.as_str(), v.as_str());
}
let client: Client<_, Full<Bytes>> = Client::builder(TokioExecutor::new()).build_http();
let r = client
.request(req.body(Full::new(Bytes::from(body.to_string()))).unwrap())
.await
.unwrap();
let code = r.status().as_u16();
let b = r.into_body().collect().await.unwrap().to_bytes();
(code, String::from_utf8_lossy(&b).into_owned())
}
async fn banc(
opts: fakego::Opts,
) -> (
SocketAddr,
Arc<fakego::FakeGo>,
tokio::sync::oneshot::Sender<()>,
tempfile::TempDir,
) {
let tmp = tempfile::tempdir().unwrap();
let (facade, go, stop, _) = fakego::start_with(tmp.path().to_path_buf(), opts).await;
(facade, go, stop, tmp)
}
fn system() -> Arc<System> {
let mut s = System::new();
s.chrome = Some("/opt/faux/chrome".into());
Arc::new(s)
}
#[tokio::test]
async fn parite_des_routes() {
let log = Arc::new(Mutex::new(Vec::new()));
let ok = fake_crawl(200, log.clone()).await;
let down = fake_crawl(500, log.clone()).await;
let nf = fake_crawl(404, log.clone()).await;
let (facade, go, _stop, _tmp) = banc(fakego::Opts {
system: Some(system()),
..Default::default()
})
.await;
let ip = sy::local_ip();
let key_re = regex::Regex::new(r"sk-loki-[0-9a-f]{48}").unwrap();
let masked_re = regex::Regex::new(r"sk-loki-[0-9a-f]…[0-9a-f]{4}").unwrap();
let unmask = |v: &str| {
v.replace("{{OK}}", &ok)
.replace("{{DOWN}}", &down)
.replace("{{NOTFOUND}}", &nf)
};
let mask = |v: &str| {
let v = v
.replace(&ok, "{{OK}}")
.replace(&down, "{{DOWN}}")
.replace(&nf, "{{NOTFOUND}}")
.replace(&format!("http://{ip}:"), "http://{{IP}}:");
let v = key_re.replace_all(&v, "{{KEY}}").into_owned();
masked_re.replace_all(&v, "{{MASKED}}").into_owned()
};
for st in fixture()["steps"].as_array().unwrap() {
let name = s(&st["name"]);
for (bucket, field) in [("config", "config"), ("state", "state")] {
for (k, v) in st[field].as_object().into_iter().flatten() {
let v = unmask(s(v));
if v.is_empty() {
go.del(bucket, k);
} else {
go.set(bucket, k, &v);
}
}
}
*go.tune_busy.lock().unwrap() = (st["tune"].as_bool() == Some(true)).then(|| {
"optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la".into()
});
log.lock().unwrap().clear();
let headers: Vec<(String, String)> = st["headers"]
.as_object()
.into_iter()
.flatten()
.map(|(k, v)| (k.clone(), s(v).to_string()))
.collect();
let (code, body) = call(
facade,
s(&st["method"]),
s(&st["path"]),
&unmask(st["body"].as_str().unwrap_or("")),
&headers,
)
.await;
assert_eq!(code as i64, st["status"].as_i64().unwrap(), "{name}");
let out: Value = serde_json::from_str(&mask(&body)).unwrap();
assert_eq!(out, st["out"], "{name}");
let mut after = BTreeMap::new();
for k in KEYS {
let (b, key) = k.split_once('/').unwrap();
if let Some(v) = go.get(b, key).filter(|v| !v.is_empty()) {
after.insert(k.to_string(), Value::String(mask(&v)));
}
}
assert_eq!(
Value::Object(after.into_iter().collect()),
st["after"],
"{name} : base"
);
let probes: Vec<Value> = log
.lock()
.unwrap()
.iter()
.map(|p| Value::String(mask(p)))
.collect();
assert_eq!(Value::Array(probes), st["probes"], "{name} : sondes");
}
}
#[test]
fn parite_du_chromium_de_playwright() {
for c in fixture()["chromium"].as_array().unwrap() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_string_lossy().into_owned();
for f in c["files"].as_array().unwrap() {
let f = s(f);
let p = tmp.path().join(f.trim_end_matches('/'));
if f.ends_with('/') {
std::fs::create_dir_all(&p).unwrap();
} else {
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, "").unwrap();
}
}
let got = sy::playwright_chromium(Some(&root), None);
assert_eq!(
got.strip_prefix(&format!("{root}/")).unwrap_or(&got),
s(&c["out"]),
"{c}"
);
}
}
#[tokio::test]
async fn ce_qui_reste_au_go() {
let (facade, go, _stop, _tmp) = banc(fakego::Opts {
system: Some(system()),
..Default::default()
})
.await;
// Le cache des outils web du Go est vidé à chaque changement d'URL, de clé
// ou de moteur — pas pour l'interrupteur seul, ni en lecture.
for (body, resets) in [
(r#"{"enabled":true}"#, 0),
(r#"{"url":"http://127.0.0.1:1"}"#, 1),
(r#"{"key":"k1"}"#, 2),
(r#"{"engine":"go"}"#, 3),
(r#"{"engine":"go","key":"","url":""}"#, 6),
(r#"{"engine":"autre","url":"x"}"#, 6),
] {
call(facade, "POST", "/api/internet", body, &[]).await;
assert_eq!(*go.reach_resets.lock().unwrap(), resets, "{body}");
}
call(facade, "GET", "/api/internet", "", &[]).await;
assert_eq!(*go.reach_resets.lock().unwrap(), 6);
// Le navigateur piloté (au Go) se ferme quand l'interrupteur se coupe.
call(facade, "POST", "/api/computer", r#"{"on":true}"#, &[]).await;
assert_eq!(*go.cdp_shutdowns.lock().unwrap(), 0);
call(facade, "POST", "/api/computer", r#"{"on":false}"#, &[]).await;
call(facade, "GET", "/api/computer", "", &[]).await;
assert_eq!(*go.cdp_shutdowns.lock().unwrap(), 1);
// Une clé neuve : le moteur est relancé s'il tourne, par le Go quand la
// façade ne le pilote pas ; jamais sur une lecture ni sur un refus.
call(facade, "GET", "/api/apikey", "", &[]).await;
call(facade, "POST", "/api/apikey", r#"{"action":"bof"}"#, &[]).await;
assert!(go.service_actions.lock().unwrap().is_empty());
call(
facade,
"POST",
"/api/apikey",
r#"{"action":"generate"}"#,
&[],
)
.await;
assert_eq!(
*go.service_actions.lock().unwrap(),
[("restart".to_string(), true)]
);
}
/// Faux superviseur du moteur (voir tests/enginectl.rs).
fn supervisor(dir: &std::path::Path) -> std::path::PathBuf {
let p = dir.join("loki-rs");
let body = "#!/bin/sh\necho \"$2\" >> \"$LOKI_HOME/trace\"\nexit 0\n";
let ok = std::process::Command::new("sh")
.args(["-c", "printf %s \"$1\" > \"$2\" && chmod 755 \"$2\""])
.arg("sh")
.arg(body)
.arg(&p)
.status()
.unwrap()
.success();
assert!(ok);
p
}
#[tokio::test]
async fn cle_neuve_moteur_pilote_par_la_facade() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
let ctl = loki_rs::enginectl::EngineCtl::new(
supervisor(&home),
vec![("LOKI_HOME".into(), home.to_string_lossy().into_owned())],
);
let (facade, go, _stop, _) = fakego::start_with(
home.clone(),
fakego::Opts {
system: Some(system()),
engine_ctl: Some(Arc::new(ctl)),
..Default::default()
},
)
.await;
let trace = || std::fs::read_to_string(home.join("trace")).unwrap_or_default();
// Moteur arrêté : rien à relancer.
let (_, b) = call(
facade,
"POST",
"/api/apikey",
r#"{"action":"set","key":"abc"}"#,
&[],
)
.await;
assert_eq!(
serde_json::from_str::<Value>(&b).unwrap()["masked"],
json!("…bc")
);
assert_eq!(trace(), "");
// Moteur actif : relancé par le superviseur, sans passer par le Go.
let mut sleeper = std::process::Command::new("sleep")
.arg("30")
.spawn()
.unwrap();
std::fs::write(home.join("loki-engine.pid"), sleeper.id().to_string()).unwrap();
call(facade, "POST", "/api/apikey", r#"{"action":"clear"}"#, &[]).await;
assert_eq!(trace(), "restart\n");
assert_eq!(*go.prestarts.lock().unwrap(), 1);
assert!(go.service_actions.lock().unwrap().is_empty());
let _ = sleeper.kill();
let _ = sleeper.wait();
}
#[tokio::test]
async fn sans_la_variable_les_interrupteurs_restent_au_go() {
let tmp = tempfile::tempdir().unwrap();
let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await;
for p in [
"/api/internet",
"/api/computer",
"/api/apikey",
"/api/network",
] {
let (_, _, b) = fakego::get(facade, p, &[]).await;
assert_eq!(&b[..], format!("go:{p}").as_bytes());
}
}
#[test]
fn fonctions_pures() {
assert_eq!(sy::key_hint("abcdé"), "…bcdé");
assert_eq!(sy::key_hint("abcd"), "…");
assert_eq!(sy::key_hint(""), "");
let k = sy::gen_api_key();
assert!(k.starts_with("sk-loki-") && k.len() == 56, "{k}");
assert_ne!(k, sy::gen_api_key());
}