Add security hardening, Ghidra, recreate skill, checkpoints, shells, web search and themes

- Sandbox (bubblewrap) for non-admin shell/git, MCP per-user rights, hard budgets and quotas,
  login rate limiting, audit log and backups
- Ghidra headless decompilation (ARM64 natives built from source), binary_info tool,
  clean room / deep workflow per session
- Recreate skill and commands for analysing and re-building old software
- File checkpoints with rewind/restore, background shells (bash_output, kill_shell)
- web_search tool (DuckDuckGo, SearXNG, Brave, Tavily)
- In-app dialogs and toasts instead of browser dialogs
- 15 base themes (trilium-themes palettes plus Nord, VS Code, GitHub, Catppuccin, One,
  Dracula, Rose Pine, Everforest, Kanagawa) with light/dark/auto switcher and license notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 19:12:30 +01:00
1 parent fab9add5d5
commit 5b569b6835
108 files changed
+6785 -279

No files matched your search

+65 -2
View File
@@ -97,6 +97,40 @@ pub fn token_from_headers(headers: &HeaderMap) -> Option<String> {
.find_map(|kv| kv.trim().strip_prefix(&format!("{COOKIE}=")).map(String::from))
}
/// Client address: the reverse proxy's X-Forwarded-For (the port is bound to localhost).
pub fn client_ip(headers: &HeaderMap) -> String {
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.or_else(|| headers.get("x-real-ip").and_then(|v| v.to_str().ok()))
.unwrap_or("local")
.trim()
.to_string()
}
const MAX_FAILURES: usize = 5;
const LOCK_MS: i64 = 15 * 60 * 1000;
/// True if this ip or username has too many recent failures.
fn locked(app: &App, keys: &[String]) -> bool {
let now = crate::types::now_ms();
let mut map = app.login_failures.lock().unwrap();
keys.iter().any(|k| {
let v = map.entry(k.clone()).or_default();
v.retain(|t| now - t < LOCK_MS);
v.len() >= MAX_FAILURES
})
}
fn record_failure(app: &App, keys: &[String]) {
let now = crate::types::now_ms();
let mut map = app.login_failures.lock().unwrap();
for k in keys {
map.entry(k.clone()).or_default().push(now);
}
}
pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
let token = token_from_headers(headers)?;
app.store.user_by_token(&sha256_hex(&token)).ok().flatten()
@@ -104,7 +138,7 @@ pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
/// Paths reachable without a login.
fn is_public(path: &str) -> bool {
["/info", "/auth/login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
["/info", "/health", "/auth/login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
.iter()
.any(|p| path.ends_with(p))
}
@@ -177,14 +211,23 @@ pub struct LoginBody {
}
pub async fn login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<LoginBody>) -> ApiResult<Response> {
let ip = client_ip(&headers);
let keys = [format!("ip:{ip}"), format!("user:{}", body.username.trim().to_lowercase())];
if locked(&app, &keys) {
app.store.audit(None, body.username.trim(), "login.blocked", "", &ip);
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many failed sign-ins – try again in 15 minutes".into()));
}
let found = app.store.user_by_name(body.username.trim())?;
// Verify even for unknown users so timing does not reveal which names exist.
let hash = found.as_ref().and_then(|(_, h)| h.clone());
let ok = verify_password(&body.password, hash.as_deref().unwrap_or("$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFy"));
let Some((user, _)) = found.filter(|(u, _)| ok && hash.is_some() && !u.disabled) else {
record_failure(&app, &keys);
app.store.audit(None, body.username.trim(), "login.failed", "", &ip);
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into()));
};
app.audit(Some(&user), "login", "password", &ip);
let cookie = issue_login(&app, &headers, &user)?;
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
}
@@ -208,7 +251,13 @@ pub async fn setup(State(app): State<Arc<App>>, headers: HeaderMap, Json(body):
let Some(expected) = expected else {
return Err(ApiError(StatusCode::CONFLICT, "setup already completed".into()));
};
let ip = client_ip(&headers);
let keys = [format!("ip:{ip}")];
if locked(&app, &keys) {
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many attempts – try again in 15 minutes".into()));
}
if body.code.trim() != expected {
record_failure(&app, &keys);
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid setup code".into()));
}
@@ -222,6 +271,7 @@ pub async fn setup(State(app): State<Arc<App>>, headers: HeaderMap, Json(body):
let user = app.store.create_user(name, name, "admin", Some(&hash_password(&body.password)?), None)?;
app.store.adopt_orphans(&user.id)?;
*app.setup_code.lock().unwrap() = None;
app.audit(Some(&user), "setup", "first admin created", &ip);
let cookie = issue_login(&app, &headers, &user)?;
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
}
@@ -305,6 +355,7 @@ pub async fn create_user(Extension(me): Extension<User>, State(app): State<Arc<A
}
let display = if body.display_name.trim().is_empty() { name } else { body.display_name.trim() };
let user = app.store.create_user(name, display, &body.role, hash.as_deref(), None)?;
app.audit(Some(&me), "user.create", format!("{} ({})", user.username, user.role), "");
Ok(Json(json!(user)))
}
@@ -314,6 +365,8 @@ pub struct UpdateUserBody {
role: Option<String>,
disabled: Option<bool>,
password: Option<String>,
/// Monthly cost limit; a negative value removes it.
monthly_quota: Option<f64>,
}
pub async fn update_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Path(id): Path<String>, Json(body): Json<UpdateUserBody>) -> ApiResult {
@@ -334,6 +387,15 @@ pub async fn update_user(Extension(me): Extension<User>, State(app): State<Arc<A
None => None,
};
app.store.update_user(&id, body.display_name.as_deref(), body.role.as_deref(), body.disabled, hash.as_deref(), None)?;
if let Some(q) = body.monthly_quota {
app.store.set_user_quota(&id, (q >= 0.0).then_some(q))?;
}
let mut changes = Vec::new();
if let Some(r) = &body.role { changes.push(format!("role={r}")); }
if let Some(d) = body.disabled { changes.push(format!("disabled={d}")); }
if hash.is_some() { changes.push("password".into()); }
if let Some(q) = body.monthly_quota { changes.push(format!("quota={q}")); }
app.audit(Some(&me), "user.update", format!("{}: {}", target.username, changes.join(", ")), "");
if hash.is_some() {
app.store.delete_user_tokens(&id)?;
}
@@ -351,6 +413,7 @@ pub async fn delete_user(Extension(me): Extension<User>, State(app): State<Arc<A
}
// Sessions stay (statistics); they become visible to admins only.
app.store.delete_user(&id)?;
app.audit(Some(&me), "user.delete", &target.username, "");
Ok(Json(json!({"ok": true})))
}
@@ -443,7 +506,7 @@ pub async fn oidc_callback(State(app): State<Arc<App>>, headers: HeaderMap, Quer
return error_page(&format!("{e}: {}", q.error_description.unwrap_or_default()));
}
match oidc_callback_inner(&app, q.code.unwrap_or_default(), q.state.unwrap_or_default()).await {
Ok(user) => match issue_login(&app, &headers, &user) {
Ok(user) => match { app.audit(Some(&user), "login", "oidc", &client_ip(&headers)); issue_login(&app, &headers, &user) } {
Ok(cookie) => ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response(),
Err(e) => error_page(&format!("{e:#}")),
},