mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-03-26 19:03:08 +00:00
Compare commits
7 commits
8007433d5f
...
40fd87aeae
| Author | SHA1 | Date | |
|---|---|---|---|
| 40fd87aeae | |||
| e16a36f13c | |||
| 0c99fa253d | |||
| dc6647efbf | |||
| 7273532b30 | |||
| 84bedd43dd | |||
| d360182093 |
20 changed files with 1147 additions and 74 deletions
210
.windsurf/plans/likwid-stabilization-6061c4.md
Normal file
210
.windsurf/plans/likwid-stabilization-6061c4.md
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
# Likwid Stabilization Megaplan (6061c4)
|
||||
|
||||
Stabilize Likwid into a production-usable system by shipping a coherent admin modular-rule management UX (built-in + WASM), making WASM packages production-grade (including background jobs + SSRF-hardening), and converging authorization on roles/permissions.
|
||||
|
||||
## Decisions (confirmed)
|
||||
- Git history: **linear `main` via squash merges**.
|
||||
- Demo VPS: **tracks `main`**.
|
||||
- Modular rules approach: **Hybrid** (built-in DB-backed settings + built-in modules + WASM plugin packages).
|
||||
- Authz direction: **roles/permissions is authoritative**.
|
||||
- Phase 1 default instance guidance: `instance_type=multi_community`, `platform_mode=admin_only`.
|
||||
- `plugin_allow_background_jobs`: **full implementation** (end-to-end semantics, not just a stored flag).
|
||||
- Registry SSRF hardening: **yes** (DNS-aware; do not rely only on host string / IP-literal checks).
|
||||
- Community admin UX scope: **full** (plugin policy + WASM packages + built-in plugins in one coherent flow).
|
||||
- Plan handling: **commit plan to `main`** once approved (plan-first discipline).
|
||||
|
||||
## Non-negotiable outcomes (Phase 1)
|
||||
- Operational reliability + install/upgrade story.
|
||||
- Admin modular rule management UX.
|
||||
- WASM third-party plugin packages are production-grade.
|
||||
- End-user UX consistency (avoid confusing partial configuration states).
|
||||
|
||||
## Current state (source-backed highlights)
|
||||
- Backend exposes community endpoints:
|
||||
- `GET/PUT /api/communities/{id}/plugin-policy`
|
||||
- `GET/POST/PUT /api/communities/{id}/plugin-packages` (+ `install-registry`)
|
||||
- WASM runtime exists (wasmtime; fuel + timeout + memory limits).
|
||||
- WASM outbound HTTP is capability-gated and allowlisted.
|
||||
- Registry allowlist currently:
|
||||
- blocks `localhost`
|
||||
- blocks IP-literal loopback/private/link-local/unspecified
|
||||
- matches exact host or `*.suffix`
|
||||
- **does not** do DNS resolution + post-resolution IP classification.
|
||||
- Frontend currently does not call `plugin-policy` / `plugin-packages` (community UI covers built-in plugins only).
|
||||
|
||||
## Scope boundaries
|
||||
- No architectural rewrite.
|
||||
- Minimal, targeted changes per milestone.
|
||||
- No dependency additions unless clearly required for an explicit acceptance criterion.
|
||||
|
||||
## Milestone discipline / verification
|
||||
- Each milestone lands as a single squash-merge PR to `main`.
|
||||
- Required verification per milestone:
|
||||
- Backend: `cargo check` (and `cargo test` if stable)
|
||||
- Frontend: `npm run build`
|
||||
- Demo VPS: deploy `main` and run `./scripts/smoke-test.sh demo`
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Baseline + operator invariants (gate)
|
||||
|
||||
### Deliverables
|
||||
- Single authoritative operator workflow for:
|
||||
- local dev start/stop
|
||||
- demo deploy/update
|
||||
- rollback
|
||||
- smoke test
|
||||
- Confirm demo systemd + compose wiring is consistent with docs and scripts.
|
||||
|
||||
### Acceptance criteria
|
||||
- Demo VPS updated to latest `main` and `./scripts/smoke-test.sh demo` passes.
|
||||
|
||||
### Verification
|
||||
- Run smoke test on VPS.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Admin modular rule management UX (hybrid)
|
||||
|
||||
### Goal
|
||||
One coherent admin flow for:
|
||||
- community built-in plugins (`plugins` + `community_plugins`)
|
||||
- community WASM plugin packages (`plugin_packages` + `community_plugin_packages`)
|
||||
- community plugin policy (`communities.settings` keys)
|
||||
|
||||
### Deliverables (frontend)
|
||||
- Community admin UI adds a “Plugins / Rules” surface that includes:
|
||||
- Built-in community plugins management (existing functionality retained).
|
||||
- Plugin policy editor (read + update):
|
||||
- trust policy
|
||||
- install sources
|
||||
- registry allowlist
|
||||
- trusted publishers
|
||||
- outbound HTTP toggle + allowlist
|
||||
- background jobs toggle
|
||||
- WASM package manager:
|
||||
- list installed packages
|
||||
- upload package
|
||||
- install from registry URL
|
||||
- activate/deactivate
|
||||
- edit package settings (schema-driven when available; raw JSON fallback)
|
||||
- Clear error messages for:
|
||||
- policy forbids uploads / registry installs
|
||||
- signature requirements fail
|
||||
- registry allowlist blocks URL
|
||||
|
||||
### Deliverables (backend contract hardening)
|
||||
- Ensure API error responses are stable and actionable for UI (status code + message consistency).
|
||||
- Ensure event emission for key actions is consistent (`public_events`).
|
||||
|
||||
### Acceptance criteria
|
||||
- As community admin/moderator:
|
||||
- can view/update plugin policy
|
||||
- can install a WASM package (upload + registry) when policy allows
|
||||
- can activate/deactivate packages
|
||||
- can edit package settings and receive server-side schema validation errors when invalid
|
||||
|
||||
### Verification
|
||||
- Manual UI walkthrough covering:
|
||||
- signed-only policy
|
||||
- registry allowlist allow/deny
|
||||
- outbound HTTP allowlist allow/deny
|
||||
- background jobs on/off behavior (see Phase 2 definition)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — WASM packages production-grade hardening
|
||||
|
||||
### 2.1 Background jobs: **full implementation**
|
||||
|
||||
#### Proposed semantics (must be implemented consistently)
|
||||
- `plugin_allow_background_jobs=false` means:
|
||||
- WASM plugins must **not** be invoked for cron hooks for that community.
|
||||
- Any future “scheduled” behavior for WASM must be gated by the same setting.
|
||||
- `plugin_allow_background_jobs=true` means:
|
||||
- WASM plugins may receive cron hooks they declare in their manifest (e.g. `cron.minute`, `cron.hourly`, `cron.daily`, ...).
|
||||
|
||||
#### Implementation outline (expected code touchpoints)
|
||||
- Resolve where WASM cron hooks are dispatched (currently cron loop exists in `backend/src/main.rs` and invokes `PluginManager::do_wasm_action_for_community`).
|
||||
- Add a community-settings check (`communities.settings.plugin_allow_background_jobs`) in the WASM cron dispatch path.
|
||||
- Ensure policy API default behavior is explicit and safe:
|
||||
- confirm default is false (current parse default is false).
|
||||
|
||||
#### Acceptance criteria
|
||||
- When `plugin_allow_background_jobs=false`, WASM cron hooks are not executed for that community.
|
||||
- When `plugin_allow_background_jobs=true`, WASM cron hooks execute normally.
|
||||
|
||||
### 2.2 Registry install SSRF hardening (DNS-aware)
|
||||
|
||||
#### Goal
|
||||
Registry install should not be able to reach internal/private addresses via DNS rebinding or private resolution.
|
||||
|
||||
#### Deliverables
|
||||
- Extend registry allowlist enforcement to:
|
||||
- resolve DNS for hostname-based registry URLs
|
||||
- reject if any resolved IP is loopback/private/link-local/unspecified/unique-local (IPv6)
|
||||
- Keep existing protections:
|
||||
- reject `localhost`
|
||||
- reject IP-literal private/loopback
|
||||
- enforce allowlist patterns
|
||||
|
||||
#### Acceptance criteria
|
||||
- Registry install is blocked when a hostname resolves to a private/loopback/link-local address.
|
||||
|
||||
### 2.3 Registry fetch hardening (timeouts/size caps)
|
||||
|
||||
#### Deliverables
|
||||
- Add explicit timeout and size bounds to registry bundle fetch.
|
||||
- Current code path uses `reqwest::get(...)` without explicit timeout/size cap.
|
||||
|
||||
#### Acceptance criteria
|
||||
- Registry fetch cannot hang indefinitely.
|
||||
- Registry fetch cannot load an unbounded payload.
|
||||
|
||||
### 2.4 Operator-visible metadata
|
||||
|
||||
#### Deliverables
|
||||
- UI shows package metadata:
|
||||
- publisher
|
||||
- sha256
|
||||
- signature present
|
||||
- source (upload/registry)
|
||||
- registry URL
|
||||
- manifest-declared hooks + capabilities
|
||||
- effective outbound HTTP permission status
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Authz convergence (roles/permissions authoritative)
|
||||
|
||||
### Goal
|
||||
Stop using `community_members.role` as the primary enforcement mechanism for privileged actions.
|
||||
|
||||
### Deliverables
|
||||
- Inventory endpoints that currently use `ensure_admin_or_moderator` style gates.
|
||||
- Introduce/confirm permissions for:
|
||||
- managing plugin policy
|
||||
- managing plugin packages
|
||||
- managing community plugin settings
|
||||
- Migrate gates to permission checks consistently.
|
||||
|
||||
### Acceptance criteria
|
||||
- Plugin policy + package management endpoints authorize via roles/permissions.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Technical debt hotspot inventory + targeted fixes
|
||||
|
||||
### Deliverables
|
||||
- Evidence-backed inventory (file/function-level) of:
|
||||
- cross-layer coupling hotspots
|
||||
- duplicated policy parsing/enforcement
|
||||
- plugin plane confusion (instance defaults vs community plugins vs wasm packages)
|
||||
- any unstable areas discovered during Phases 1–3
|
||||
- Only fix hotspots that block Phase 1–3 acceptance criteria.
|
||||
|
||||
---
|
||||
|
||||
## Commit plan (after this plan is approved)
|
||||
- Add this plan into the repo under `.windsurf/plans/` and commit to `main`.
|
||||
- Implementation starts only after the plan commit lands.
|
||||
|
|
@ -26,6 +26,12 @@ JWT_SECRET=change-me-in-production
|
|||
# =============================================================================
|
||||
# The backend applies a global fixed-window rate limiter (60s window).
|
||||
#
|
||||
# If you run the backend behind a reverse proxy, you may want to configure trusted proxy IPs
|
||||
# so the backend can safely use X-Forwarded-For / X-Real-IP for rate limiting.
|
||||
#
|
||||
# Comma-separated IP allowlist (examples: 127.0.0.1,::1,10.0.0.10)
|
||||
TRUSTED_PROXY_IPS=
|
||||
#
|
||||
# - Set RATE_LIMIT_ENABLED=false to disable all rate limiting.
|
||||
# - Set any *_RPM variable to 0 to disable that specific limiter.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -63,6 +63,42 @@ async fn register(
|
|||
Extension(config): Extension<Arc<Config>>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
||||
if req.username.trim().len() < 3 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Username must be at least 3 characters".to_string(),
|
||||
));
|
||||
}
|
||||
if req.username.len() > 50 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Username must be at most 50 characters".to_string(),
|
||||
));
|
||||
}
|
||||
if req.email.trim().is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "Email is required".to_string()));
|
||||
}
|
||||
if req.email.len() > 255 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Email must be at most 255 characters".to_string(),
|
||||
));
|
||||
}
|
||||
if req.password.len() < 8 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Password must be at least 8 characters".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(display_name) = &req.display_name {
|
||||
if display_name.len() > 100 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Display name must be at most 100 characters".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Check registration settings
|
||||
let settings = sqlx::query!(
|
||||
"SELECT registration_enabled, registration_mode FROM instance_settings LIMIT 1"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ use sqlx::PgPool;
|
|||
use sqlx::Row;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::net::lookup_host;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
|
|
@ -443,7 +445,10 @@ fn verify_signature_if_required(
|
|||
Ok(Some(sig_arr.to_vec()))
|
||||
}
|
||||
|
||||
fn enforce_registry_allowlist(url: &Url, allowlist: &[String]) -> Result<(), (StatusCode, String)> {
|
||||
async fn enforce_registry_allowlist(
|
||||
url: &Url,
|
||||
allowlist: &[String],
|
||||
) -> Result<(), (StatusCode, String)> {
|
||||
let host = url.host_str().ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Registry URL must include host".to_string(),
|
||||
|
|
@ -477,11 +482,66 @@ fn enforce_registry_allowlist(url: &Url, allowlist: &[String]) -> Result<(), (St
|
|||
));
|
||||
}
|
||||
|
||||
if !allowlist.is_empty() && !allowlist.iter().any(|h| h == host) {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Registry host not in allowlist".to_string(),
|
||||
));
|
||||
if !allowlist.is_empty() {
|
||||
let allowed = allowlist.iter().any(|pattern| {
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(stripped) = pattern.strip_prefix("*.") {
|
||||
let suffix = format!(".{stripped}");
|
||||
host.ends_with(&suffix) || host.eq_ignore_ascii_case(stripped)
|
||||
} else {
|
||||
host.eq_ignore_ascii_case(pattern)
|
||||
}
|
||||
});
|
||||
|
||||
if !allowed {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Registry host not in allowlist".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let port = url.port_or_known_default().unwrap_or(443);
|
||||
let lookup = tokio::time::timeout(Duration::from_secs(3), lookup_host((host, port)))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Registry host DNS lookup timed out".to_string(),
|
||||
)
|
||||
})
|
||||
.and_then(|res| {
|
||||
res.map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Registry host DNS lookup failed".to_string(),
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
for addr in lookup {
|
||||
let ip = addr.ip();
|
||||
let is_disallowed = match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
v6.is_loopback()
|
||||
|| v6.is_unique_local()
|
||||
|| v6.is_unicast_link_local()
|
||||
|| v6.is_unspecified()
|
||||
}
|
||||
};
|
||||
|
||||
if is_disallowed {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Registry host is not allowed".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -1049,9 +1109,18 @@ async fn install_registry_plugin_package(
|
|||
}
|
||||
}
|
||||
|
||||
enforce_registry_allowlist(&url, ®istry_allowlist)?;
|
||||
enforce_registry_allowlist(&url, ®istry_allowlist).await?;
|
||||
|
||||
let res = reqwest::get(url.clone())
|
||||
const MAX_REGISTRY_BUNDLE_BYTES: u64 = 2 * 1024 * 1024;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let mut res = client
|
||||
.get(url.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||
|
||||
|
|
@ -1059,7 +1128,31 @@ async fn install_registry_plugin_package(
|
|||
return Err((StatusCode::BAD_GATEWAY, "Registry fetch failed".to_string()));
|
||||
}
|
||||
|
||||
let bundle: UploadPluginPackageRequest = res.json().await.map_err(|_| {
|
||||
if let Some(len) = res.content_length() {
|
||||
if len > MAX_REGISTRY_BUNDLE_BYTES {
|
||||
return Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Registry response too large".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut body: Vec<u8> = Vec::new();
|
||||
while let Some(chunk) = res
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|_| (StatusCode::BAD_GATEWAY, "Registry fetch failed".to_string()))?
|
||||
{
|
||||
body.extend_from_slice(&chunk);
|
||||
if body.len() as u64 > MAX_REGISTRY_BUNDLE_BYTES {
|
||||
return Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Registry response too large".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let bundle: UploadPluginPackageRequest = serde_json::from_slice(&body).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Invalid registry response".to_string(),
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ use serde::{Deserialize, Serialize};
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::api::permissions::{perms, require_permission};
|
||||
use crate::auth::AuthUser;
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
|
|
|
|||
|
|
@ -277,7 +277,8 @@ async fn complete_setup(
|
|||
if req.instance_type != "single_community" && req.platform_mode == "single_community" {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Platform mode 'single_community' requires instance type 'single_community'".to_string(),
|
||||
"Platform mode 'single_community' requires instance type 'single_community'"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if req.platform_mode == "single_community" {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize};
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::api::permissions::{perms, require_permission};
|
||||
use crate::auth::AuthUser;
|
||||
use crate::models::user::UserResponse;
|
||||
|
||||
pub fn router(pool: PgPool) -> Router {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ pub struct Config {
|
|||
pub server_port: u16,
|
||||
#[serde(default)]
|
||||
pub cors_allowed_origins: Option<String>,
|
||||
#[serde(default)]
|
||||
pub trusted_proxy_ips: Option<String>,
|
||||
/// Enable demo mode - restricts destructive actions and enables demo accounts
|
||||
#[serde(default)]
|
||||
pub demo_mode: bool,
|
||||
|
|
@ -77,6 +79,7 @@ impl Default for Config {
|
|||
server_host: "127.0.0.1".to_string(),
|
||||
server_port: 3000,
|
||||
cors_allowed_origins: None,
|
||||
trusted_proxy_ips: None,
|
||||
demo_mode: false,
|
||||
jwt_secret: default_jwt_secret(),
|
||||
rate_limit_enabled: default_rate_limit_enabled(),
|
||||
|
|
|
|||
|
|
@ -334,7 +334,9 @@ async fn run() -> Result<(), StartupError> {
|
|||
|
||||
// WASM plugins need per-community context.
|
||||
let community_ids: Vec<Uuid> =
|
||||
match sqlx::query_scalar("SELECT id FROM communities WHERE is_active = true")
|
||||
match sqlx::query_scalar(
|
||||
"SELECT id FROM communities WHERE is_active = true AND settings->>'plugin_allow_background_jobs' = 'true'",
|
||||
)
|
||||
.fetch_all(&cron_pool)
|
||||
.await
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ use crate::config::Config;
|
|||
#[derive(Clone)]
|
||||
pub struct RateLimitState {
|
||||
config: Arc<Config>,
|
||||
trusted_proxies: Vec<IpAddr>,
|
||||
ip: Arc<FixedWindowLimiter>,
|
||||
user: Arc<FixedWindowLimiter>,
|
||||
auth: Arc<FixedWindowLimiter>,
|
||||
|
|
@ -26,6 +27,7 @@ impl RateLimitState {
|
|||
pub fn new(config: Arc<Config>) -> Self {
|
||||
let window = Duration::from_secs(60);
|
||||
Self {
|
||||
trusted_proxies: parse_trusted_proxy_ips(&config),
|
||||
ip: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_ip_rpm)),
|
||||
user: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_user_rpm)),
|
||||
auth: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_auth_rpm)),
|
||||
|
|
@ -121,6 +123,43 @@ fn parse_ip_from_connect_info<B>(request: &Request<B>) -> Option<IpAddr> {
|
|||
.map(|ci| ci.0.ip())
|
||||
}
|
||||
|
||||
fn parse_trusted_proxy_ips(config: &Config) -> Vec<IpAddr> {
|
||||
let mut out: Vec<IpAddr> = Vec::new();
|
||||
|
||||
let raw = config
|
||||
.trusted_proxy_ips
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty());
|
||||
|
||||
let Some(raw) = raw else {
|
||||
return out;
|
||||
};
|
||||
|
||||
for part in raw.split(',').map(|v| v.trim()).filter(|v| !v.is_empty()) {
|
||||
match part.parse::<IpAddr>() {
|
||||
Ok(ip) => out.push(ip),
|
||||
Err(_) => {
|
||||
tracing::warn!(value = part, "Invalid TRUSTED_PROXY_IPS entry; ignoring");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn is_trusted_proxy(peer_ip: Option<IpAddr>, extra_trusted: &[IpAddr]) -> bool {
|
||||
let Some(peer_ip) = peer_ip else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if peer_ip.is_loopback() {
|
||||
return true;
|
||||
}
|
||||
|
||||
extra_trusted.iter().any(|ip| ip == &peer_ip)
|
||||
}
|
||||
|
||||
fn parse_user_from_auth(headers: &HeaderMap, secret: &str) -> Option<Uuid> {
|
||||
let auth = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
|
||||
let mut pieces = auth.split_whitespace();
|
||||
|
|
@ -162,7 +201,12 @@ pub async fn rate_limit_middleware(
|
|||
}
|
||||
|
||||
let headers = request.headers();
|
||||
let ip = parse_ip_from_headers(headers).or_else(|| parse_ip_from_connect_info(&request));
|
||||
let peer_ip = parse_ip_from_connect_info(&request);
|
||||
let ip = if is_trusted_proxy(peer_ip, &state.trusted_proxies) {
|
||||
parse_ip_from_headers(headers).or(peer_ip)
|
||||
} else {
|
||||
peer_ip
|
||||
};
|
||||
|
||||
let ip_key = ip
|
||||
.map(|v| format!("ip:{}", v))
|
||||
|
|
@ -212,9 +256,11 @@ pub async fn rate_limit_middleware(
|
|||
mod tests {
|
||||
use super::{rate_limit_middleware, FixedWindowLimiter, RateLimitState};
|
||||
use axum::body::Body;
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{header, Request, StatusCode};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
|
@ -295,22 +341,26 @@ mod tests {
|
|||
));
|
||||
|
||||
for _ in 0..2 {
|
||||
let req = Request::builder()
|
||||
let mut req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/ping")
|
||||
.header("x-forwarded-for", "1.2.3.4")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut()
|
||||
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
let req = Request::builder()
|
||||
let mut req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/ping")
|
||||
.header("x-forwarded-for", "1.2.3.4")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut()
|
||||
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
assert!(res.headers().get(header::RETRY_AFTER).is_some());
|
||||
|
|
@ -333,21 +383,25 @@ mod tests {
|
|||
rate_limit_middleware,
|
||||
));
|
||||
|
||||
let req = Request::builder()
|
||||
let mut req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/auth/login")
|
||||
.header("x-forwarded-for", "1.2.3.4")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut()
|
||||
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
let req = Request::builder()
|
||||
let mut req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/auth/login")
|
||||
.header("x-forwarded-for", "1.2.3.4")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut()
|
||||
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
assert!(res.headers().get(header::RETRY_AFTER).is_some());
|
||||
|
|
@ -374,23 +428,108 @@ mod tests {
|
|||
rate_limit_middleware,
|
||||
));
|
||||
|
||||
let req = Request::builder()
|
||||
let mut req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/ping")
|
||||
.header("x-forwarded-for", "1.2.3.4")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut()
|
||||
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
let req = Request::builder()
|
||||
let mut req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/ping")
|
||||
.header("x-forwarded-for", "1.2.3.4")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut()
|
||||
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
assert!(res.headers().get(header::RETRY_AFTER).is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forwarded_ip_respected_when_peer_is_trusted_proxy() {
|
||||
let cfg = Arc::new(Config {
|
||||
rate_limit_enabled: true,
|
||||
rate_limit_ip_rpm: 1,
|
||||
rate_limit_user_rpm: 0,
|
||||
rate_limit_auth_rpm: 0,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/ping", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
RateLimitState::new(cfg.clone()),
|
||||
rate_limit_middleware,
|
||||
));
|
||||
|
||||
let mut req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/ping")
|
||||
.header("x-forwarded-for", "1.1.1.1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut()
|
||||
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
let mut req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/ping")
|
||||
.header("x-forwarded-for", "2.2.2.2")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut()
|
||||
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forwarded_ip_ignored_when_peer_is_not_trusted_proxy() {
|
||||
let cfg = Arc::new(Config {
|
||||
rate_limit_enabled: true,
|
||||
rate_limit_ip_rpm: 1,
|
||||
rate_limit_user_rpm: 0,
|
||||
rate_limit_auth_rpm: 0,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/ping", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
RateLimitState::new(cfg.clone()),
|
||||
rate_limit_middleware,
|
||||
));
|
||||
|
||||
let mut req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/ping")
|
||||
.header("x-forwarded-for", "1.1.1.1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut()
|
||||
.insert(ConnectInfo(SocketAddr::from(([10, 0, 0, 2], 12345))));
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
let mut req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/ping")
|
||||
.header("x-forwarded-for", "2.2.2.2")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut()
|
||||
.insert(ConnectInfo(SocketAddr::from(([10, 0, 0, 2], 12345))));
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
assert!(res.headers().get(header::RETRY_AFTER).is_some());
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ DB_PORT=5433
|
|||
JWT_SECRET=demo_jwt_secret_not_for_production
|
||||
BACKEND_PORT=3001
|
||||
BACKEND_BIND_HOST=0.0.0.0
|
||||
TRUSTED_PROXY_IPS=
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=4322
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ DB_PORT=5432
|
|||
|
||||
# Backend
|
||||
JWT_SECRET=CHANGE_THIS_TO_RANDOM_64_CHAR_STRING
|
||||
TRUSTED_PROXY_IPS=
|
||||
BACKEND_PORT=3000
|
||||
|
||||
# Frontend
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ services:
|
|||
environment:
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-likwid_demo}:${POSTGRES_PASSWORD:-demo_secret_change_me}@postgres:5432/${POSTGRES_DB:-likwid_demo}
|
||||
JWT_SECRET: ${JWT_SECRET:-demo_jwt_secret_not_for_production}
|
||||
TRUSTED_PROXY_IPS: ${TRUSTED_PROXY_IPS:-}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||
RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
|
||||
RATE_LIMIT_IP_RPM: ${RATE_LIMIT_IP_RPM:-300}
|
||||
RATE_LIMIT_USER_RPM: ${RATE_LIMIT_USER_RPM:-1200}
|
||||
RATE_LIMIT_AUTH_RPM: ${RATE_LIMIT_AUTH_RPM:-30}
|
||||
SERVER_HOST: 0.0.0.0
|
||||
SERVER_PORT: 3000
|
||||
DEMO_MODE: "true"
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ services:
|
|||
environment:
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-likwid}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-likwid_prod}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
TRUSTED_PROXY_IPS: ${TRUSTED_PROXY_IPS:-}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||
RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
|
||||
RATE_LIMIT_IP_RPM: ${RATE_LIMIT_IP_RPM:-300}
|
||||
RATE_LIMIT_USER_RPM: ${RATE_LIMIT_USER_RPM:-1200}
|
||||
RATE_LIMIT_AUTH_RPM: ${RATE_LIMIT_AUTH_RPM:-30}
|
||||
SERVER_HOST: 0.0.0.0
|
||||
SERVER_PORT: 3000
|
||||
DEMO_MODE: "false"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ Likwid is configured through environment variables and database settings.
|
|||
| `SERVER_PORT` | No | `3000` | HTTP port |
|
||||
| `DEMO_MODE` | No | `false` | Enable demo features |
|
||||
| `CORS_ALLOWED_ORIGINS` | No | - | Comma-separated allowlist of CORS origins for browsers (recommended in production) |
|
||||
| `TRUSTED_PROXY_IPS` | No | - | Comma-separated allowlist of reverse proxy IPs whose `X-Forwarded-For` / `X-Real-IP` headers should be trusted |
|
||||
| `RATE_LIMIT_ENABLED` | No | `true` | Enable API rate limiting |
|
||||
| `RATE_LIMIT_IP_RPM` | No | `300` | Requests per minute per IP |
|
||||
| `RATE_LIMIT_USER_RPM` | No | `1200` | Requests per minute per authenticated user |
|
||||
|
|
@ -102,9 +103,12 @@ Behavior:
|
|||
|
||||
IP detection order:
|
||||
|
||||
- `x-forwarded-for` (first IP in list)
|
||||
- `x-real-ip`
|
||||
- TCP peer address (Axum `ConnectInfo`)
|
||||
- If the TCP peer is a trusted proxy (loopback by default, plus `TRUSTED_PROXY_IPS`):
|
||||
- `x-forwarded-for` (first IP in list)
|
||||
- `x-real-ip`
|
||||
- TCP peer address (Axum `ConnectInfo`)
|
||||
- Otherwise:
|
||||
- TCP peer address (Axum `ConnectInfo`)
|
||||
|
||||
Responses when limited:
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,8 @@ For production, use a reverse proxy (nginx, Caddy) with:
|
|||
- Proper headers
|
||||
- HSTS (set on the reverse proxy)
|
||||
|
||||
If you want per-IP rate limiting to use `X-Forwarded-For` / `X-Real-IP`, set `TRUSTED_PROXY_IPS` to the reverse proxy IP(s) (loopback is trusted by default).
|
||||
|
||||
Example nginx config:
|
||||
|
||||
```nginx
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ This guide describes a practical, operator-first way to run Likwid on openSUSE L
|
|||
## Assumptions
|
||||
|
||||
- You have an SSH-accessible server running openSUSE Leap.
|
||||
- You run Likwid as a dedicated non-root user (recommended: `deploy`).
|
||||
- You run Likwid as a dedicated non-root user (example: `deploy`).
|
||||
- A reverse proxy (Caddy/nginx) terminates TLS and forwards:
|
||||
- `/` to the frontend
|
||||
- `/api` to the backend
|
||||
|
|
@ -55,6 +55,7 @@ cp ~/likwid/compose/.env.production.example ~/likwid/compose/.env.production
|
|||
- `POSTGRES_PASSWORD`
|
||||
- `JWT_SECRET`
|
||||
- `API_BASE` (should be your public URL, e.g. `https://your.domain`)
|
||||
- `TRUSTED_PROXY_IPS` (if your reverse proxy does not connect from loopback)
|
||||
|
||||
1. Start services:
|
||||
|
||||
|
|
|
|||
|
|
@ -12,17 +12,10 @@ Securing your Likwid instance.
|
|||
|
||||
### Password Policy
|
||||
|
||||
- Minimum 8 characters (configurable)
|
||||
- Bcrypt hashing with cost factor 12
|
||||
- Minimum 8 characters
|
||||
- Argon2 password hashing (server-side)
|
||||
- No password in logs or error messages
|
||||
|
||||
### Two-Factor Authentication
|
||||
|
||||
Enable 2FA support for users:
|
||||
|
||||
- TOTP (Time-based One-Time Password)
|
||||
- Backup codes for recovery
|
||||
|
||||
## Network Security
|
||||
|
||||
### HTTPS
|
||||
|
|
@ -67,6 +60,8 @@ Protect against abuse:
|
|||
- 1200 requests/minute per authenticated user
|
||||
- 30 requests/minute per IP for auth endpoints
|
||||
|
||||
If you run the backend behind a reverse proxy, configure `TRUSTED_PROXY_IPS` so the backend can safely use `X-Forwarded-For` / `X-Real-IP` when applying per-IP limits.
|
||||
|
||||
## Database Security
|
||||
|
||||
### Connection
|
||||
|
|
@ -104,15 +99,13 @@ All inputs are validated:
|
|||
|
||||
## Moderation Audit Trail
|
||||
|
||||
All moderation actions are logged:
|
||||
Moderation actions are recorded:
|
||||
|
||||
- Who performed the action
|
||||
- What action was taken
|
||||
- Why (reason required)
|
||||
- When it happened
|
||||
|
||||
Logs are immutable and tamper-evident.
|
||||
|
||||
## Updates
|
||||
|
||||
Keep Likwid updated:
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||
const url = new URL(context.request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
const forwardedProto = context.request.headers.get('x-forwarded-proto')?.trim().toLowerCase();
|
||||
const isHttps = url.protocol === 'https:' || forwardedProto === 'https';
|
||||
|
||||
if (path === '/demo' && url.searchParams.get('enter') === '1') {
|
||||
let nextPath = url.searchParams.get('next') || '/communities';
|
||||
if (!nextPath.startsWith('/')) {
|
||||
|
|
@ -27,6 +30,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isHttps,
|
||||
maxAge: 60 * 60 * 12,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -117,6 +117,277 @@ const { slug } = Astro.params;
|
|||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchPluginPolicy(communityId) {
|
||||
const res = await fetch(`${apiBase}/api/communities/${communityId}/plugin-policy`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(err || 'Failed to load plugin policy');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchPluginPackages(communityId) {
|
||||
const res = await fetch(`${apiBase}/api/communities/${communityId}/plugin-packages`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(err || 'Failed to load plugin packages');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function normalizeStringList(text) {
|
||||
return String(text || '')
|
||||
.split(/\r?\n/)
|
||||
.map(v => v.trim())
|
||||
.filter(v => v.length > 0);
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const chunkSize = 0x8000;
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode.apply(null, chunk);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function renderPolicyEditor(policy) {
|
||||
const trust = policy?.trust_policy || 'signed_only';
|
||||
const installSources = new Set(policy?.install_sources || []);
|
||||
const allowOutbound = !!policy?.allow_outbound_http;
|
||||
const httpAllowlist = (policy?.http_egress_allowlist || []).join('\n');
|
||||
const registryAllowlist = (policy?.registry_allowlist || []).join('\n');
|
||||
const allowBg = !!policy?.allow_background_jobs;
|
||||
const publishers = (policy?.trusted_publishers || []).join('\n');
|
||||
|
||||
return `
|
||||
<section class="ui-card policy-card">
|
||||
<div class="policy-head">
|
||||
<h2>Plugin Policy</h2>
|
||||
<div class="policy-actions">
|
||||
<button type="button" class="ui-btn ui-btn-primary" id="save-policy">Save Policy</button>
|
||||
<span class="status" id="policy-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui-form" style="--ui-form-control-max-width: 560px; --ui-form-group-mb: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="policy-trust">Trust policy</label>
|
||||
<select id="policy-trust">
|
||||
<option value="signed_only" ${trust === 'signed_only' ? 'selected' : ''}>Signed only</option>
|
||||
<option value="unsigned_allowed" ${trust === 'unsigned_allowed' ? 'selected' : ''}>Unsigned allowed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Install sources</label>
|
||||
<div class="policy-checkboxes">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="policy-source-upload" ${installSources.has('upload') ? 'checked' : ''} />
|
||||
<span>Upload</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="policy-source-registry" ${installSources.has('registry') ? 'checked' : ''} />
|
||||
<span>Registry</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="policy-allow-outbound" ${allowOutbound ? 'checked' : ''} />
|
||||
<span>Allow outbound HTTP (WASM)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="policy-http-allowlist">HTTP egress allowlist (one per line)</label>
|
||||
<textarea id="policy-http-allowlist" spellcheck="false" rows="4">${escapeHtml(httpAllowlist)}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="policy-registry-allowlist">Registry allowlist (one host per line; exact or *.suffix)</label>
|
||||
<textarea id="policy-registry-allowlist" spellcheck="false" rows="4">${escapeHtml(registryAllowlist)}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="policy-trusted-publishers">Trusted publishers (one Ed25519 key per line)</label>
|
||||
<textarea id="policy-trusted-publishers" spellcheck="false" rows="4">${escapeHtml(publishers)}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="policy-allow-background" ${allowBg ? 'checked' : ''} />
|
||||
<span>Allow background jobs (WASM cron hooks)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function packageSettingsSchema(pkg) {
|
||||
const schema = pkg?.manifest?.settings_schema;
|
||||
if (schema && schema.properties) return schema;
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderPackageSettingsForm(pkg) {
|
||||
const schema = packageSettingsSchema(pkg);
|
||||
const name = String(pkg?.package_id || '');
|
||||
const settings = pkg?.settings || {};
|
||||
if (!schema) {
|
||||
const settingsText = JSON.stringify(settings ?? {}, null, 2);
|
||||
return `
|
||||
<div class="settings-body ui-form" style="--ui-form-group-mb: 0.75rem;">
|
||||
<p class="settings-hint">No schema defined. Edit raw JSON:</p>
|
||||
<textarea class="settings-json" spellcheck="false" data-package-id="${escapeHtml(name)}">${escapeHtml(settingsText)}</textarea>
|
||||
<div class="settings-actions">
|
||||
<button class="ui-btn ui-btn-primary js-save-package-json" data-package-id="${escapeHtml(name)}">Save Settings</button>
|
||||
<span class="status" id="pkg-status-${escapeHtml(name)}"></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const fields = Object.entries(schema.properties).map(([key, propSchema]) => {
|
||||
return renderSchemaField(`pkg-${name}`, key, propSchema, settings?.[key]);
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<form class="settings-form ui-form pkg-settings-form" data-package-id="${escapeHtml(name)}" style="--ui-form-group-mb: 1rem;">
|
||||
${fields}
|
||||
<div class="settings-actions">
|
||||
<button type="submit" class="ui-btn ui-btn-primary">Save Settings</button>
|
||||
<span class="status" id="pkg-status-${escapeHtml(name)}"></span>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPackages(packages) {
|
||||
const list = Array.isArray(packages) ? packages : [];
|
||||
|
||||
return `
|
||||
<section class="ui-card packages-card">
|
||||
<div class="packages-head">
|
||||
<h2>WASM Plugin Packages</h2>
|
||||
<div class="packages-actions">
|
||||
<button type="button" class="ui-btn ui-btn-secondary" id="refresh-packages">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui-form" style="--ui-form-control-max-width: 560px; --ui-form-group-mb: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="registry-url">Install from registry URL</label>
|
||||
<div class="inline-row">
|
||||
<input type="url" id="registry-url" placeholder="https://registry.example/plugin-bundle.json" />
|
||||
<button type="button" class="ui-btn ui-btn-primary" id="install-registry">Install</button>
|
||||
</div>
|
||||
<span class="status" id="registry-status"></span>
|
||||
</div>
|
||||
|
||||
<details class="panel ui-card" open>
|
||||
<summary class="panel-summary">
|
||||
<span class="panel-title">Upload package</span>
|
||||
<span class="panel-meta"><span class="ui-badge">WASM</span></span>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
<form id="upload-form" class="ui-form" style="--ui-form-group-mb: 0.75rem;">
|
||||
<div class="form-group">
|
||||
<label for="upload-name">Name</label>
|
||||
<input id="upload-name" type="text" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="upload-version">Version</label>
|
||||
<input id="upload-version" type="text" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="upload-description">Description</label>
|
||||
<input id="upload-description" type="text" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="upload-publisher">Publisher (Ed25519 public key, base64)</label>
|
||||
<input id="upload-publisher" type="text" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="upload-signature">Signature (base64, optional)</label>
|
||||
<input id="upload-signature" type="text" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="upload-manifest">Manifest (JSON)</label>
|
||||
<textarea id="upload-manifest" spellcheck="false" rows="6" required>{\n "name": "",\n "version": "",\n "description": "",\n "hooks": [],\n "capabilities": []\n}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="upload-wasm">WASM file</label>
|
||||
<input id="upload-wasm" type="file" accept=".wasm,application/wasm" required />
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button type="submit" class="ui-btn ui-btn-primary" id="upload-btn">Upload</button>
|
||||
<span class="status" id="upload-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="packages-list">
|
||||
${list.length === 0 ? `
|
||||
<div class="state-card ui-card">
|
||||
<p class="empty">No packages installed.</p>
|
||||
<p class="hint">Install from registry or upload a WASM bundle.</p>
|
||||
</div>
|
||||
` : list.map(pkg => {
|
||||
const pkgId = String(pkg.package_id);
|
||||
const title = `${escapeHtml(pkg.name)} <span class="version">v${escapeHtml(pkg.version)}</span>`;
|
||||
return `
|
||||
<div class="plugin-card ui-card" data-package-id="${escapeHtml(pkgId)}">
|
||||
<div class="plugin-head">
|
||||
<div class="plugin-title">
|
||||
<h3>${title}</h3>
|
||||
${pkg.signature_present ? `<span class="ui-pill ui-pill-core">signed</span>` : `<span class="ui-pill ui-pill-disabled">unsigned</span>`}
|
||||
${pkg.source ? `<span class="ui-pill">${escapeHtml(pkg.source)}</span>` : ''}
|
||||
</div>
|
||||
<div class="plugin-controls">
|
||||
<label class="switch" title="Activate/deactivate">
|
||||
<input class="package-toggle" type="checkbox" data-package-id="${escapeHtml(pkgId)}" ${pkg.is_active ? 'checked' : ''} />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="desc">${escapeHtml(pkg.description || '')}</p>
|
||||
<div class="pkg-meta">
|
||||
<div><span class="k">publisher</span> <span class="v">${escapeHtml(pkg.publisher || '')}</span></div>
|
||||
<div><span class="k">sha256</span> <span class="v">${escapeHtml(pkg.wasm_sha256 || '')}</span></div>
|
||||
${pkg.registry_url ? `<div><span class="k">registry</span> <span class="v">${escapeHtml(pkg.registry_url)}</span></div>` : ''}
|
||||
</div>
|
||||
<details class="panel ui-card settings-panel">
|
||||
<summary class="panel-summary">
|
||||
<span class="panel-title">Settings</span>
|
||||
<span class="panel-meta">${packageSettingsSchema(pkg) ? 'Schema' : '<span class="ui-badge">JSON</span>'}</span>
|
||||
</summary>
|
||||
<div class="panel-body settings-body-wrap">
|
||||
${renderPackageSettingsForm(pkg)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
async function fetchPlugins(communityId) {
|
||||
const res = await fetch(`${apiBase}/api/communities/${communityId}/plugins`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
|
|
@ -207,7 +478,7 @@ const { slug } = Astro.params;
|
|||
`;
|
||||
}
|
||||
|
||||
function renderPlugins(community, membership, plugins) {
|
||||
function renderPlugins(community, membership, policy, packages, plugins) {
|
||||
const container = document.getElementById('plugins-content');
|
||||
if (!container) return;
|
||||
|
||||
|
|
@ -218,21 +489,7 @@ const { slug } = Astro.params;
|
|||
return;
|
||||
}
|
||||
|
||||
if (!plugins || plugins.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="state-card ui-card">
|
||||
<p class="empty">No plugins available.</p>
|
||||
<p class="hint">If this seems wrong, try reloading the page.</p>
|
||||
<div class="state-actions">
|
||||
<button type="button" class="ui-btn ui-btn-primary" id="retry-plugins-empty">Retry</button>
|
||||
<a class="ui-btn ui-btn-secondary" href="/communities/${encodeURIComponent(String(slug || ''))}">Back to community</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('retry-plugins-empty')?.addEventListener('click', load);
|
||||
return;
|
||||
}
|
||||
const safePlugins = Array.isArray(plugins) ? plugins : [];
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="community-badge ui-card">
|
||||
|
|
@ -243,8 +500,16 @@ const { slug } = Astro.params;
|
|||
<div class="community-badge-subtitle">Community plugin settings</div>
|
||||
</div>
|
||||
|
||||
${renderPolicyEditor(policy)}
|
||||
|
||||
${renderPackages(packages)}
|
||||
|
||||
<div class="plugins-list">
|
||||
${plugins.map(p => {
|
||||
${safePlugins.length === 0 ? `
|
||||
<div class="state-card ui-card">
|
||||
<p class="empty">No built-in plugins available.</p>
|
||||
</div>
|
||||
` : safePlugins.map(p => {
|
||||
const disabledGlobally = !p.global_is_active;
|
||||
const isCore = !!p.is_core;
|
||||
const checked = !!p.community_is_active;
|
||||
|
|
@ -286,6 +551,296 @@ const { slug } = Astro.params;
|
|||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('refresh-packages')?.addEventListener('click', load);
|
||||
|
||||
document.getElementById('save-policy')?.addEventListener('click', async () => {
|
||||
const statusEl = document.getElementById('policy-status');
|
||||
const btn = document.getElementById('save-policy');
|
||||
if (btn) btn.disabled = true;
|
||||
if (statusEl) statusEl.textContent = 'Saving...';
|
||||
|
||||
try {
|
||||
const current = {
|
||||
trust_policy: (document.getElementById('policy-trust'))?.value,
|
||||
install_sources: [
|
||||
(document.getElementById('policy-source-upload'))?.checked ? 'upload' : null,
|
||||
(document.getElementById('policy-source-registry'))?.checked ? 'registry' : null,
|
||||
].filter(Boolean),
|
||||
allow_outbound_http: (document.getElementById('policy-allow-outbound'))?.checked,
|
||||
http_egress_allowlist: normalizeStringList((document.getElementById('policy-http-allowlist'))?.value),
|
||||
registry_allowlist: normalizeStringList((document.getElementById('policy-registry-allowlist'))?.value),
|
||||
trusted_publishers: normalizeStringList((document.getElementById('policy-trusted-publishers'))?.value),
|
||||
allow_background_jobs: (document.getElementById('policy-allow-background'))?.checked,
|
||||
};
|
||||
|
||||
const patch = {};
|
||||
if (!policy || current.trust_policy !== policy.trust_policy) patch.trust_policy = current.trust_policy;
|
||||
if (!policy || JSON.stringify(current.install_sources) !== JSON.stringify(policy.install_sources || [])) patch.install_sources = current.install_sources;
|
||||
if (!policy || current.allow_outbound_http !== !!policy.allow_outbound_http) patch.allow_outbound_http = current.allow_outbound_http;
|
||||
if (!policy || JSON.stringify(current.http_egress_allowlist) !== JSON.stringify(policy.http_egress_allowlist || [])) patch.http_egress_allowlist = current.http_egress_allowlist;
|
||||
if (!policy || JSON.stringify(current.registry_allowlist) !== JSON.stringify(policy.registry_allowlist || [])) patch.registry_allowlist = current.registry_allowlist;
|
||||
if (!policy || JSON.stringify(current.trusted_publishers) !== JSON.stringify(policy.trusted_publishers || [])) patch.trusted_publishers = current.trusted_publishers;
|
||||
if (!policy || current.allow_background_jobs !== !!policy.allow_background_jobs) patch.allow_background_jobs = current.allow_background_jobs;
|
||||
|
||||
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-policy`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
|
||||
if (statusEl) statusEl.textContent = 'Saved';
|
||||
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (statusEl) statusEl.textContent = 'Error';
|
||||
alert(err?.message || 'Failed to save policy');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('install-registry')?.addEventListener('click', async () => {
|
||||
const input = document.getElementById('registry-url');
|
||||
const statusEl = document.getElementById('registry-status');
|
||||
const btn = document.getElementById('install-registry');
|
||||
const url = input?.value;
|
||||
|
||||
if (!url) return;
|
||||
if (btn) btn.disabled = true;
|
||||
if (statusEl) statusEl.textContent = 'Installing...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/install-registry`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
|
||||
if (statusEl) statusEl.textContent = 'Installed';
|
||||
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
|
||||
if (input) input.value = '';
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (statusEl) statusEl.textContent = 'Error';
|
||||
alert(err?.message || 'Failed to install from registry');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('upload-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('upload-btn');
|
||||
const statusEl = document.getElementById('upload-status');
|
||||
if (btn) btn.disabled = true;
|
||||
if (statusEl) statusEl.textContent = 'Uploading...';
|
||||
|
||||
try {
|
||||
const name = (document.getElementById('upload-name'))?.value;
|
||||
const version = (document.getElementById('upload-version'))?.value;
|
||||
const description = (document.getElementById('upload-description'))?.value || null;
|
||||
const publisher = (document.getElementById('upload-publisher'))?.value || null;
|
||||
const signature_base64 = (document.getElementById('upload-signature'))?.value || null;
|
||||
const manifestText = (document.getElementById('upload-manifest'))?.value;
|
||||
const wasmFile = (document.getElementById('upload-wasm'))?.files?.[0];
|
||||
|
||||
if (!name || !version || !manifestText || !wasmFile) {
|
||||
throw new Error('Missing required fields');
|
||||
}
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(manifestText);
|
||||
} catch {
|
||||
throw new Error('Invalid manifest JSON');
|
||||
}
|
||||
|
||||
const wasmBuf = await wasmFile.arrayBuffer();
|
||||
const wasm_base64 = arrayBufferToBase64(wasmBuf);
|
||||
|
||||
const body = {
|
||||
name,
|
||||
version,
|
||||
description,
|
||||
publisher,
|
||||
manifest,
|
||||
wasm_base64,
|
||||
signature_base64,
|
||||
};
|
||||
|
||||
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
|
||||
if (statusEl) statusEl.textContent = 'Uploaded';
|
||||
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (statusEl) statusEl.textContent = 'Error';
|
||||
alert(err?.message || 'Failed to upload package');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.package-toggle').forEach(el => {
|
||||
el.addEventListener('change', async (e) => {
|
||||
const input = e.target;
|
||||
const packageId = input.dataset.packageId;
|
||||
const next = input.checked;
|
||||
|
||||
input.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/${packageId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ is_active: next }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
input.checked = !next;
|
||||
alert(err || 'Failed to update package');
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
} catch (err) {
|
||||
input.checked = !next;
|
||||
alert('Failed to update package');
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.js-save-package-json').forEach(el => {
|
||||
el.addEventListener('click', async (e) => {
|
||||
const btn = e.target;
|
||||
const packageId = btn.dataset.packageId;
|
||||
const textarea = document.querySelector(`textarea.settings-json[data-package-id="${packageId}"]`);
|
||||
const statusEl = document.getElementById(`pkg-status-${packageId}`);
|
||||
if (!textarea) return;
|
||||
|
||||
let settings;
|
||||
try {
|
||||
settings = JSON.parse(textarea.value || '{}');
|
||||
} catch {
|
||||
if (statusEl) statusEl.textContent = 'Invalid JSON';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
if (statusEl) statusEl.textContent = 'Saving...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/${packageId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ settings }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
if (statusEl) statusEl.textContent = 'Saved';
|
||||
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
|
||||
await load();
|
||||
} else {
|
||||
const err = await res.text();
|
||||
if (statusEl) statusEl.textContent = 'Error';
|
||||
alert(err || 'Failed to save settings');
|
||||
}
|
||||
} catch {
|
||||
if (statusEl) statusEl.textContent = 'Error';
|
||||
alert('Failed to save settings');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.pkg-settings-form').forEach(form => {
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const packageId = form.dataset.packageId;
|
||||
const statusEl = document.getElementById(`pkg-status-${packageId}`);
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
|
||||
const settings = {};
|
||||
form.querySelectorAll('[data-key]').forEach(input => {
|
||||
const key = input.dataset.key;
|
||||
const type = input.dataset.type;
|
||||
|
||||
if (type === 'boolean') {
|
||||
settings[key] = input.checked;
|
||||
} else if (type === 'integer') {
|
||||
settings[key] = input.value ? parseInt(input.value, 10) : null;
|
||||
} else if (type === 'number') {
|
||||
settings[key] = input.value ? parseFloat(input.value) : null;
|
||||
} else {
|
||||
settings[key] = input.value;
|
||||
}
|
||||
});
|
||||
|
||||
if (btn) btn.disabled = true;
|
||||
if (statusEl) statusEl.textContent = 'Saving...';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/${packageId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ settings }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
if (statusEl) statusEl.textContent = 'Saved';
|
||||
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500);
|
||||
await load();
|
||||
} else {
|
||||
const err = await res.text();
|
||||
if (statusEl) statusEl.textContent = 'Error';
|
||||
alert(err || 'Failed to save settings');
|
||||
}
|
||||
} catch {
|
||||
if (statusEl) statusEl.textContent = 'Error';
|
||||
alert('Failed to save settings');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.plugin-toggle').forEach(el => {
|
||||
el.addEventListener('change', async (e) => {
|
||||
const input = e.target;
|
||||
|
|
@ -436,9 +991,19 @@ const { slug } = Astro.params;
|
|||
}
|
||||
|
||||
const membership = await fetchMembership(community.id);
|
||||
const plugins = await fetchPlugins(community.id);
|
||||
const allowed = membership?.role === 'admin' || membership?.role === 'moderator';
|
||||
if (!allowed) {
|
||||
renderForbiddenState(community?.name || 'this community');
|
||||
return;
|
||||
}
|
||||
|
||||
renderPlugins(community, membership, plugins);
|
||||
const [policy, packages, plugins] = await Promise.all([
|
||||
fetchPluginPolicy(community.id),
|
||||
fetchPluginPackages(community.id),
|
||||
fetchPlugins(community.id),
|
||||
]);
|
||||
|
||||
renderPlugins(community, membership, policy, packages, plugins);
|
||||
} catch (err) {
|
||||
const msg = err?.message || 'Failed to load plugins.';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue