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).
|
# 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 RATE_LIMIT_ENABLED=false to disable all rate limiting.
|
||||||
# - Set any *_RPM variable to 0 to disable that specific limiter.
|
# - Set any *_RPM variable to 0 to disable that specific limiter.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,42 @@ async fn register(
|
||||||
Extension(config): Extension<Arc<Config>>,
|
Extension(config): Extension<Arc<Config>>,
|
||||||
Json(req): Json<RegisterRequest>,
|
Json(req): Json<RegisterRequest>,
|
||||||
) -> Result<Json<AuthResponse>, (StatusCode, String)> {
|
) -> 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
|
// Check registration settings
|
||||||
let settings = sqlx::query!(
|
let settings = sqlx::query!(
|
||||||
"SELECT registration_enabled, registration_mode FROM instance_settings LIMIT 1"
|
"SELECT registration_enabled, registration_mode FROM instance_settings LIMIT 1"
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ use sqlx::PgPool;
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::net::lookup_host;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
|
|
@ -443,7 +445,10 @@ fn verify_signature_if_required(
|
||||||
Ok(Some(sig_arr.to_vec()))
|
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((
|
let host = url.host_str().ok_or((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
"Registry URL must include host".to_string(),
|
"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) {
|
if !allowlist.is_empty() {
|
||||||
return Err((
|
let allowed = allowlist.iter().any(|pattern| {
|
||||||
StatusCode::FORBIDDEN,
|
if pattern == "*" {
|
||||||
"Registry host not in allowlist".to_string(),
|
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(())
|
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
|
.await
|
||||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
.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()));
|
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,
|
StatusCode::BAD_GATEWAY,
|
||||||
"Invalid registry response".to_string(),
|
"Invalid registry response".to_string(),
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
|
||||||
use crate::api::permissions::{perms, require_permission};
|
use crate::api::permissions::{perms, require_permission};
|
||||||
|
use crate::auth::AuthUser;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
|
|
|
||||||
|
|
@ -277,7 +277,8 @@ async fn complete_setup(
|
||||||
if req.instance_type != "single_community" && req.platform_mode == "single_community" {
|
if req.instance_type != "single_community" && req.platform_mode == "single_community" {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::BAD_REQUEST,
|
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" {
|
if req.platform_mode == "single_community" {
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
|
||||||
use crate::api::permissions::{perms, require_permission};
|
use crate::api::permissions::{perms, require_permission};
|
||||||
|
use crate::auth::AuthUser;
|
||||||
use crate::models::user::UserResponse;
|
use crate::models::user::UserResponse;
|
||||||
|
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ pub struct Config {
|
||||||
pub server_port: u16,
|
pub server_port: u16,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cors_allowed_origins: Option<String>,
|
pub cors_allowed_origins: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub trusted_proxy_ips: Option<String>,
|
||||||
/// Enable demo mode - restricts destructive actions and enables demo accounts
|
/// Enable demo mode - restricts destructive actions and enables demo accounts
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub demo_mode: bool,
|
pub demo_mode: bool,
|
||||||
|
|
@ -77,6 +79,7 @@ impl Default for Config {
|
||||||
server_host: "127.0.0.1".to_string(),
|
server_host: "127.0.0.1".to_string(),
|
||||||
server_port: 3000,
|
server_port: 3000,
|
||||||
cors_allowed_origins: None,
|
cors_allowed_origins: None,
|
||||||
|
trusted_proxy_ips: None,
|
||||||
demo_mode: false,
|
demo_mode: false,
|
||||||
jwt_secret: default_jwt_secret(),
|
jwt_secret: default_jwt_secret(),
|
||||||
rate_limit_enabled: default_rate_limit_enabled(),
|
rate_limit_enabled: default_rate_limit_enabled(),
|
||||||
|
|
|
||||||
|
|
@ -334,7 +334,9 @@ async fn run() -> Result<(), StartupError> {
|
||||||
|
|
||||||
// WASM plugins need per-community context.
|
// WASM plugins need per-community context.
|
||||||
let community_ids: Vec<Uuid> =
|
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)
|
.fetch_all(&cron_pool)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ use crate::config::Config;
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RateLimitState {
|
pub struct RateLimitState {
|
||||||
config: Arc<Config>,
|
config: Arc<Config>,
|
||||||
|
trusted_proxies: Vec<IpAddr>,
|
||||||
ip: Arc<FixedWindowLimiter>,
|
ip: Arc<FixedWindowLimiter>,
|
||||||
user: Arc<FixedWindowLimiter>,
|
user: Arc<FixedWindowLimiter>,
|
||||||
auth: Arc<FixedWindowLimiter>,
|
auth: Arc<FixedWindowLimiter>,
|
||||||
|
|
@ -26,6 +27,7 @@ impl RateLimitState {
|
||||||
pub fn new(config: Arc<Config>) -> Self {
|
pub fn new(config: Arc<Config>) -> Self {
|
||||||
let window = Duration::from_secs(60);
|
let window = Duration::from_secs(60);
|
||||||
Self {
|
Self {
|
||||||
|
trusted_proxies: parse_trusted_proxy_ips(&config),
|
||||||
ip: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_ip_rpm)),
|
ip: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_ip_rpm)),
|
||||||
user: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_user_rpm)),
|
user: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_user_rpm)),
|
||||||
auth: Arc::new(FixedWindowLimiter::new(window, config.rate_limit_auth_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())
|
.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> {
|
fn parse_user_from_auth(headers: &HeaderMap, secret: &str) -> Option<Uuid> {
|
||||||
let auth = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
|
let auth = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
|
||||||
let mut pieces = auth.split_whitespace();
|
let mut pieces = auth.split_whitespace();
|
||||||
|
|
@ -162,7 +201,12 @@ pub async fn rate_limit_middleware(
|
||||||
}
|
}
|
||||||
|
|
||||||
let headers = request.headers();
|
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
|
let ip_key = ip
|
||||||
.map(|v| format!("ip:{}", v))
|
.map(|v| format!("ip:{}", v))
|
||||||
|
|
@ -212,9 +256,11 @@ pub async fn rate_limit_middleware(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{rate_limit_middleware, FixedWindowLimiter, RateLimitState};
|
use super::{rate_limit_middleware, FixedWindowLimiter, RateLimitState};
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
|
use axum::extract::ConnectInfo;
|
||||||
use axum::http::{header, Request, StatusCode};
|
use axum::http::{header, Request, StatusCode};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
@ -295,22 +341,26 @@ mod tests {
|
||||||
));
|
));
|
||||||
|
|
||||||
for _ in 0..2 {
|
for _ in 0..2 {
|
||||||
let req = Request::builder()
|
let mut req = Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
.uri("/api/ping")
|
.uri("/api/ping")
|
||||||
.header("x-forwarded-for", "1.2.3.4")
|
.header("x-forwarded-for", "1.2.3.4")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
req.extensions_mut()
|
||||||
|
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||||
let res = app.clone().oneshot(req).await.unwrap();
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
assert_eq!(res.status(), StatusCode::OK);
|
assert_eq!(res.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
let req = Request::builder()
|
let mut req = Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
.uri("/api/ping")
|
.uri("/api/ping")
|
||||||
.header("x-forwarded-for", "1.2.3.4")
|
.header("x-forwarded-for", "1.2.3.4")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
req.extensions_mut()
|
||||||
|
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||||
let res = app.clone().oneshot(req).await.unwrap();
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||||
assert!(res.headers().get(header::RETRY_AFTER).is_some());
|
assert!(res.headers().get(header::RETRY_AFTER).is_some());
|
||||||
|
|
@ -333,21 +383,25 @@ mod tests {
|
||||||
rate_limit_middleware,
|
rate_limit_middleware,
|
||||||
));
|
));
|
||||||
|
|
||||||
let req = Request::builder()
|
let mut req = Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
.uri("/api/auth/login")
|
.uri("/api/auth/login")
|
||||||
.header("x-forwarded-for", "1.2.3.4")
|
.header("x-forwarded-for", "1.2.3.4")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
req.extensions_mut()
|
||||||
|
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||||
let res = app.clone().oneshot(req).await.unwrap();
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
assert_eq!(res.status(), StatusCode::OK);
|
assert_eq!(res.status(), StatusCode::OK);
|
||||||
|
|
||||||
let req = Request::builder()
|
let mut req = Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
.uri("/api/auth/login")
|
.uri("/api/auth/login")
|
||||||
.header("x-forwarded-for", "1.2.3.4")
|
.header("x-forwarded-for", "1.2.3.4")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
req.extensions_mut()
|
||||||
|
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||||
let res = app.clone().oneshot(req).await.unwrap();
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||||
assert!(res.headers().get(header::RETRY_AFTER).is_some());
|
assert!(res.headers().get(header::RETRY_AFTER).is_some());
|
||||||
|
|
@ -374,23 +428,108 @@ mod tests {
|
||||||
rate_limit_middleware,
|
rate_limit_middleware,
|
||||||
));
|
));
|
||||||
|
|
||||||
let req = Request::builder()
|
let mut req = Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
.uri("/api/ping")
|
.uri("/api/ping")
|
||||||
.header("x-forwarded-for", "1.2.3.4")
|
.header("x-forwarded-for", "1.2.3.4")
|
||||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
req.extensions_mut()
|
||||||
|
.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345))));
|
||||||
let res = app.clone().oneshot(req).await.unwrap();
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
assert_eq!(res.status(), StatusCode::OK);
|
assert_eq!(res.status(), StatusCode::OK);
|
||||||
|
|
||||||
let req = Request::builder()
|
let mut req = Request::builder()
|
||||||
.method("GET")
|
.method("GET")
|
||||||
.uri("/api/ping")
|
.uri("/api/ping")
|
||||||
.header("x-forwarded-for", "1.2.3.4")
|
.header("x-forwarded-for", "1.2.3.4")
|
||||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap();
|
.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();
|
let res = app.clone().oneshot(req).await.unwrap();
|
||||||
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||||
assert!(res.headers().get(header::RETRY_AFTER).is_some());
|
assert!(res.headers().get(header::RETRY_AFTER).is_some());
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ DB_PORT=5433
|
||||||
JWT_SECRET=demo_jwt_secret_not_for_production
|
JWT_SECRET=demo_jwt_secret_not_for_production
|
||||||
BACKEND_PORT=3001
|
BACKEND_PORT=3001
|
||||||
BACKEND_BIND_HOST=0.0.0.0
|
BACKEND_BIND_HOST=0.0.0.0
|
||||||
|
TRUSTED_PROXY_IPS=
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
FRONTEND_PORT=4322
|
FRONTEND_PORT=4322
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
# Production Environment Configuration
|
# Production Environment Configuration
|
||||||
# Copy to .env.production and fill in values
|
# Copy to .env.production and fill in values
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
POSTGRES_USER=likwid
|
POSTGRES_USER=likwid
|
||||||
POSTGRES_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
|
POSTGRES_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
|
||||||
POSTGRES_DB=likwid_prod
|
POSTGRES_DB=likwid_prod
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
|
||||||
# Backend
|
# Backend
|
||||||
JWT_SECRET=CHANGE_THIS_TO_RANDOM_64_CHAR_STRING
|
JWT_SECRET=CHANGE_THIS_TO_RANDOM_64_CHAR_STRING
|
||||||
BACKEND_PORT=3000
|
TRUSTED_PROXY_IPS=
|
||||||
|
BACKEND_PORT=3000
|
||||||
# Frontend
|
|
||||||
FRONTEND_PORT=4321
|
# Frontend
|
||||||
API_BASE=https://your-domain.com
|
FRONTEND_PORT=4321
|
||||||
INTERNAL_API_BASE=http://backend:3000
|
API_BASE=https://your-domain.com
|
||||||
|
INTERNAL_API_BASE=http://backend:3000
|
||||||
# Note: DEMO_MODE is always false for production
|
|
||||||
|
# Note: DEMO_MODE is always false for production
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,12 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-likwid_demo}:${POSTGRES_PASSWORD:-demo_secret_change_me}@postgres:5432/${POSTGRES_DB:-likwid_demo}
|
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}
|
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_HOST: 0.0.0.0
|
||||||
SERVER_PORT: 3000
|
SERVER_PORT: 3000
|
||||||
DEMO_MODE: "true"
|
DEMO_MODE: "true"
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-likwid}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-likwid_prod}
|
DATABASE_URL: postgres://${POSTGRES_USER:-likwid}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-likwid_prod}
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
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_HOST: 0.0.0.0
|
||||||
SERVER_PORT: 3000
|
SERVER_PORT: 3000
|
||||||
DEMO_MODE: "false"
|
DEMO_MODE: "false"
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ Likwid is configured through environment variables and database settings.
|
||||||
| `SERVER_PORT` | No | `3000` | HTTP port |
|
| `SERVER_PORT` | No | `3000` | HTTP port |
|
||||||
| `DEMO_MODE` | No | `false` | Enable demo features |
|
| `DEMO_MODE` | No | `false` | Enable demo features |
|
||||||
| `CORS_ALLOWED_ORIGINS` | No | - | Comma-separated allowlist of CORS origins for browsers (recommended in production) |
|
| `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_ENABLED` | No | `true` | Enable API rate limiting |
|
||||||
| `RATE_LIMIT_IP_RPM` | No | `300` | Requests per minute per IP |
|
| `RATE_LIMIT_IP_RPM` | No | `300` | Requests per minute per IP |
|
||||||
| `RATE_LIMIT_USER_RPM` | No | `1200` | Requests per minute per authenticated user |
|
| `RATE_LIMIT_USER_RPM` | No | `1200` | Requests per minute per authenticated user |
|
||||||
|
|
@ -102,9 +103,12 @@ Behavior:
|
||||||
|
|
||||||
IP detection order:
|
IP detection order:
|
||||||
|
|
||||||
- `x-forwarded-for` (first IP in list)
|
- If the TCP peer is a trusted proxy (loopback by default, plus `TRUSTED_PROXY_IPS`):
|
||||||
- `x-real-ip`
|
- `x-forwarded-for` (first IP in list)
|
||||||
- TCP peer address (Axum `ConnectInfo`)
|
- `x-real-ip`
|
||||||
|
- TCP peer address (Axum `ConnectInfo`)
|
||||||
|
- Otherwise:
|
||||||
|
- TCP peer address (Axum `ConnectInfo`)
|
||||||
|
|
||||||
Responses when limited:
|
Responses when limited:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,8 @@ For production, use a reverse proxy (nginx, Caddy) with:
|
||||||
- Proper headers
|
- Proper headers
|
||||||
- HSTS (set on the reverse proxy)
|
- 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:
|
Example nginx config:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ This guide describes a practical, operator-first way to run Likwid on openSUSE L
|
||||||
## Assumptions
|
## Assumptions
|
||||||
|
|
||||||
- You have an SSH-accessible server running openSUSE Leap.
|
- 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:
|
- A reverse proxy (Caddy/nginx) terminates TLS and forwards:
|
||||||
- `/` to the frontend
|
- `/` to the frontend
|
||||||
- `/api` to the backend
|
- `/api` to the backend
|
||||||
|
|
@ -55,6 +55,7 @@ cp ~/likwid/compose/.env.production.example ~/likwid/compose/.env.production
|
||||||
- `POSTGRES_PASSWORD`
|
- `POSTGRES_PASSWORD`
|
||||||
- `JWT_SECRET`
|
- `JWT_SECRET`
|
||||||
- `API_BASE` (should be your public URL, e.g. `https://your.domain`)
|
- `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:
|
1. Start services:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,10 @@ Securing your Likwid instance.
|
||||||
|
|
||||||
### Password Policy
|
### Password Policy
|
||||||
|
|
||||||
- Minimum 8 characters (configurable)
|
- Minimum 8 characters
|
||||||
- Bcrypt hashing with cost factor 12
|
- Argon2 password hashing (server-side)
|
||||||
- No password in logs or error messages
|
- 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
|
## Network Security
|
||||||
|
|
||||||
### HTTPS
|
### HTTPS
|
||||||
|
|
@ -67,6 +60,8 @@ Protect against abuse:
|
||||||
- 1200 requests/minute per authenticated user
|
- 1200 requests/minute per authenticated user
|
||||||
- 30 requests/minute per IP for auth endpoints
|
- 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
|
## Database Security
|
||||||
|
|
||||||
### Connection
|
### Connection
|
||||||
|
|
@ -104,15 +99,13 @@ All inputs are validated:
|
||||||
|
|
||||||
## Moderation Audit Trail
|
## Moderation Audit Trail
|
||||||
|
|
||||||
All moderation actions are logged:
|
Moderation actions are recorded:
|
||||||
|
|
||||||
- Who performed the action
|
- Who performed the action
|
||||||
- What action was taken
|
- What action was taken
|
||||||
- Why (reason required)
|
- Why (reason required)
|
||||||
- When it happened
|
- When it happened
|
||||||
|
|
||||||
Logs are immutable and tamper-evident.
|
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
Keep Likwid updated:
|
Keep Likwid updated:
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||||
const url = new URL(context.request.url);
|
const url = new URL(context.request.url);
|
||||||
const path = url.pathname;
|
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') {
|
if (path === '/demo' && url.searchParams.get('enter') === '1') {
|
||||||
let nextPath = url.searchParams.get('next') || '/communities';
|
let nextPath = url.searchParams.get('next') || '/communities';
|
||||||
if (!nextPath.startsWith('/')) {
|
if (!nextPath.startsWith('/')) {
|
||||||
|
|
@ -27,6 +30,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
|
secure: isHttps,
|
||||||
maxAge: 60 * 60 * 12,
|
maxAge: 60 * 60 * 12,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,277 @@ const { slug } = Astro.params;
|
||||||
return res.json();
|
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) {
|
async function fetchPlugins(communityId) {
|
||||||
const res = await fetch(`${apiBase}/api/communities/${communityId}/plugins`, {
|
const res = await fetch(`${apiBase}/api/communities/${communityId}/plugins`, {
|
||||||
headers: { 'Authorization': `Bearer ${token}` },
|
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');
|
const container = document.getElementById('plugins-content');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
|
@ -218,21 +489,7 @@ const { slug } = Astro.params;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!plugins || plugins.length === 0) {
|
const safePlugins = Array.isArray(plugins) ? plugins : [];
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="community-badge ui-card">
|
<div class="community-badge ui-card">
|
||||||
|
|
@ -243,8 +500,16 @@ const { slug } = Astro.params;
|
||||||
<div class="community-badge-subtitle">Community plugin settings</div>
|
<div class="community-badge-subtitle">Community plugin settings</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${renderPolicyEditor(policy)}
|
||||||
|
|
||||||
|
${renderPackages(packages)}
|
||||||
|
|
||||||
<div class="plugins-list">
|
<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 disabledGlobally = !p.global_is_active;
|
||||||
const isCore = !!p.is_core;
|
const isCore = !!p.is_core;
|
||||||
const checked = !!p.community_is_active;
|
const checked = !!p.community_is_active;
|
||||||
|
|
@ -286,6 +551,296 @@ const { slug } = Astro.params;
|
||||||
</div>
|
</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 => {
|
document.querySelectorAll('.plugin-toggle').forEach(el => {
|
||||||
el.addEventListener('change', async (e) => {
|
el.addEventListener('change', async (e) => {
|
||||||
const input = e.target;
|
const input = e.target;
|
||||||
|
|
@ -436,9 +991,19 @@ const { slug } = Astro.params;
|
||||||
}
|
}
|
||||||
|
|
||||||
const membership = await fetchMembership(community.id);
|
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) {
|
} catch (err) {
|
||||||
const msg = err?.message || 'Failed to load plugins.';
|
const msg = err?.message || 'Failed to load plugins.';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue