Reorganise docs: move historical files to archive/, merge overlapping docs

- Move 12 historical/superseded docs + 1 session log + 1 PDF + 1 HTML plan to archive/
- Merge 4 VM-crash docs into archive/vm-crash-incident.md
- Merge LDAP plan + spec into ldap.md
- Merge FilePond race investigation into filepond-crash-analysis.md
- Merge SPECS.md client notes into spec-sheet.md appendix
- Update README index and security.md cross-reference
This commit is contained in:
Pontoporeia
2026-08-24 11:36:02 +02:00
parent e4b48867aa
commit 55c714a35d
27 changed files with 501 additions and 1602 deletions
-137
View File
@@ -1,137 +0,0 @@
# Evidence Summary - VM Crash Investigation
## 🎯 Verdict: NOT the posterg application's fault
---
## Key Evidence
### 1. Serial Getty Crash Loop (THE CULPRIT)
```
$ grep -c "serial-getty" journal_previous_boot.log
1,264,488 crashes
$ grep "restart counter is at" journal_previous_boot.log | tail -1
Mar 04 10:43:45: Scheduled restart job, restart counter is at 421491
$ echo "421491 restarts / 6 per minute = $(echo '421491/6/60/24' | bc) days"
48.7 days of continuous crashing
```
**Error message:**
```
agetty[1078654]: could not get terminal name: -22
agetty[1078654]: -: failed to get terminal attributes: Input/output error
```
---
### 2. OOM Killer Triggered
```
Mar 04 10:45:54 - MariaDB: Memory pressure event
Mar 04 10:50:23 - systemd invoked oom-killer
Mar 04 10:51:13 - php-fpm8.4 mentioned in OOM process list
```
**Timeline:**
- 50 days of serial-getty crash loop → memory exhaustion → OOM killer
---
### 3. PHP-FPM was HEALTHY
```
$ grep "Consumed.*memory peak" php-fpm_service.log
Jan 26: 11.1M memory peak
Feb 05: 11.2M memory peak
No crashes, no errors, normal operation ✅
```
---
### 4. Nginx was HEALTHY
```
$ head posterg_error.log
(empty before crash)
$ head posterg_error.log.2.gz
(errors are from AFTER the reboot - Mar 24, database schema issues)
```
The 234KB error log is from March 26 (security scanner attacks, all properly blocked).
---
### 5. Access Patterns were NORMAL
```
$ awk '{print $1}' posterg_access.log | sort -u
192.168.6.11
Only internal/development IP accessing the site.
```
---
## Visual Timeline
```
Jan 13 ┌─────────────────────────────────────────────┐
│ Boot - serial-getty starts crash loop │
│ (crashes every 10 seconds) │
│ │
│ ↓ Memory slowly consumed by: │
│ - Process spawning overhead │
│ - Journal entries (1.2M × 200 bytes) │
│ - systemd tracking structures │
│ │
Mar 04 │ 10:45 - MariaDB: Memory pressure ⚠️ │
10:50 │ 10:50 - OOM Killer triggered 💥 │
│ 10:51 - System becomes unresponsive │
└─────────────────────────────────────────────┘
[ 20-day gap - system frozen/limping ]
Mar 24 ┌─────────────────────────────────────────────┐
12:56 │ Technicians force reboot │
│ System comes back online cleanly │
└─────────────────────────────────────────────┘
```
---
## What was NOT the problem
❌ PHP memory leaks
❌ Nginx configuration issues
❌ Database corruption
❌ DDoS attack
❌ Application bugs
❌ File upload abuse
❌ Rate limit bypass
✅ **Misconfigured QEMU/KVM serial console**
---
## The Fix
```bash
sudo systemctl stop serial-getty@ttyS0.service
sudo systemctl disable serial-getty@ttyS0.service
sudo systemctl mask serial-getty@ttyS0.service
```
**Result:** Will never crash from this again.
---
## Confidence Level
🟢🟢🟢🟢🟢 **100% CERTAIN**
Evidence is conclusive:
- Direct kernel OOM logs
- 1.2M crash entries in journal
- Clear error messages
- Clean application logs
- Known QEMU serial console bug pattern
-55
View File
@@ -1,55 +0,0 @@
# IMMEDIATE FIX - VM Crash Prevention
## TL;DR
**Root Cause:** Serial console service (`serial-getty@ttyS0`) crashed 421,491 times over 50 days, exhausting memory.
**NOT caused by:** Your posterg website/application (it's innocent!)
## The Fix (5 minutes, zero downtime)
SSH into the server and run:
```bash
ssh theophile@posterg.erg.be -p 3274
# Disable the broken serial console service
sudo systemctl stop serial-getty@ttyS0.service
sudo systemctl disable serial-getty@ttyS0.service
sudo systemctl mask serial-getty@ttyS0.service
# Verify it's masked
sudo systemctl status serial-getty@ttyS0.service
# Should show: "Loaded: masked"
# Check system health
free -h
systemctl --failed
```
## Done!
The VM will no longer crash from this issue. See `VM_Crash_Analysis_FINAL.md` for complete details.
## Bonus: Clean Up the Database Schema Errors
While you're there, fix the post-reboot database issues:
```bash
cd /var/www/posterg
# Check which migrations need to run
ls -la storage/migrations/
# If migrations exist, apply them manually or:
# Review and fix the missing 'tags' table and 'ts.role' column
sqlite3 storage/posterg.db "SELECT name FROM sqlite_master WHERE type='table';"
```
---
**Summary Stats:**
- Serial getty crashes: **1,264,488**
- Restart counter at OOM: **421,491**
- Days until OOM: **~50 days**
- Your application's fault: **0%** ✅
-207
View File
@@ -1,207 +0,0 @@
# LDAP Authentication — Migration Plan
## Context
The admin panel currently uses a two-layer auth stack:
1. **nginx `auth_basic`** — browser password prompt, credentials stored in
`/etc/nginx/.htpasswd-xamxam`, managed manually with `htpasswd`.
2. **PHP `AdminAuth`** — session guard with bcrypt hash stored in the SQLite
database (`site_settings.admin_password_hash`).
The client runs an org-wide LDAP service already used for other internal tools.
The goal is to replace both layers with a single LDAP-backed PHP login, so that
staff use their existing org credentials and account lifecycle (onboarding,
offboarding, password resets) is handled centrally.
**Chosen approach: Option 3 — PHP LDAP auth, nginx `auth_basic` removed.**
No nginx module compilation required. The existing `AdminAuth` session
architecture stays intact; only the credential-verification back-end changes.
---
## Network prerequisite (blocker)
XAMXAM runs in a VM that may not have direct TCP access to the LDAP server
(port 389 plain / port 636 LDAPS). This must be confirmed before any
implementation work starts.
**Action required (client):**
- Confirm the LDAP server hostname / IP and port (prefer 636 LDAPS).
- Open a firewall rule from the XAMXAM VM to the LDAP server on that port.
- Provide a **read-only service-account** DN and password for the bind
(e.g. `cn=xamxam-svc,ou=services,dc=erg,dc=be`). This account only needs
permission to search the directory — never to write.
- Confirm the LDAP server type (OpenLDAP / Active Directory / 389-DS / other)
and the base DN for staff accounts (e.g. `ou=staff,dc=erg,dc=be`).
- Confirm the attribute that holds the login name (`uid` on OpenLDAP,
`sAMAccountName` on AD).
- Confirm whether a group membership check is required (i.e. only members of
`cn=xamxam-admins,ou=groups,dc=erg,dc=be` may log in), or whether any valid
staff account is sufficient.
**Verify TCP reachability from the VM before writing any code:**
```bash
# On the XAMXAM server
nc -zv <ldap-host> 636 # LDAPS (preferred)
nc -zv <ldap-host> 389 # plain LDAP (fallback, only on a trusted LAN)
```
---
## TODO
### Phase 1 — Server preparation
- [ ] Confirm network access (see blocker above).
- [ ] Install the PHP LDAP extension on the server:
```bash
sudo apt install php8.4-ldap
sudo systemctl restart php8.4-fpm
```
- [ ] Verify the extension loaded:
```bash
php -m | grep ldap
```
- [ ] Store LDAP connection parameters in the database (`site_settings` table)
or in a server-side env file — **never in the repository**:
- `ldap_host` — e.g. `ldaps://ldap.erg.be`
- `ldap_port` — `636`
- `ldap_bind_dn` — service-account DN
- `ldap_bind_password` — service-account password
- `ldap_base_dn` — search base for user accounts
- `ldap_user_attr` — login attribute (`uid` / `sAMAccountName`)
- `ldap_group_dn` — (optional) required group DN; empty = no group check
### Phase 2 — New `LdapAuth` class
Create `app/src/LdapAuth.php`:
```
LdapAuth::verify(string $username, string $password): bool
```
Internal steps:
1. Load connection parameters from `Database::getSetting()`.
2. Open connection: `ldap_connect($host, $port)`.
3. Set options: `LDAP_OPT_PROTOCOL_VERSION = 3`,
`LDAP_OPT_REFERRALS = 0`,
`LDAP_OPT_NETWORK_TIMEOUT = 3` (fail fast — don't stall page loads).
4. Service-account bind: `ldap_bind($conn, $bind_dn, $bind_password)`.
5. Search for the user:
`ldap_search($conn, $base_dn, "($attr=$username)", ['dn'])`.
6. Extract the user DN from search results.
7. If group check is configured: verify membership with a second search
against the group DN before proceeding.
8. Attempt user bind with the supplied password:
`ldap_bind($conn, $user_dn, $password)` — this is the actual
credential verification; LDAP does the password check.
9. `ldap_unbind($conn)`.
10. Return `true` on success, `false` on any failure.
Error handling:
- Catch `ldap_error()` / `ldap_errno()` on every step.
- Log failures to the PHP error log (never expose LDAP error strings to
the browser).
- On LDAP server unreachable: fail **closed** (deny access, show a
"service temporarily unavailable" message — do not fall through to a
bypass).
### Phase 3 — Modify `AdminAuth`
`AdminAuth` currently verifies credentials in two places:
| Location | Change |
|---|---|
| `AdminAuth::login()` | Replace `password_verify($password, $hash)` with `LdapAuth::verify($username, $password)` |
| `AdminAuth::requireLogin()` — nginx Basic Auth passthrough (`$_SERVER['PHP_AUTH_PW']`) | Remove entirely (nginx `auth_basic` will be gone) |
| `AdminAuth::getStoredHash()` | Can be removed or kept as dead code path |
| `AdminAuth::setPasswordHash()` / `removePasswordHash()` | Retire (no longer used) |
The session logic (`SESSION_KEY`, `session_regenerate_id`, cookie hardening,
`logout()`) is unchanged — it is auth-method-agnostic.
The login form (`/admin/login.php`) gains a `username` field alongside
`password`. The `account.php` password-change page is retired (password
management happens in the LDAP directory, not here).
### Phase 4 — Modify the login form
`app/public/admin/login.php` and `app/templates/admin/login.php`:
- Add `<input type="text" name="username">` before the password field.
- Remove the "change password" link (password is managed in LDAP).
- POST handler calls `AdminAuth::login($username, $password)` with both args.
### Phase 5 — Remove nginx `auth_basic`
In `nginx/xamxam.conf`, inside `location ^~ /admin/`:
```nginx
# Remove these two lines:
auth_basic "Admin Access - XAMXAM";
auth_basic_user_file /etc/nginx/.htpasswd-xamxam;
```
The rate-limiting zone (`limit_req zone=admin`) stays — it still guards
against brute-force on the PHP login form.
Update `scripts/deploy-server.sh` and `scripts/manage-admin-users.sh` to
note that htpasswd management is no longer required.
Clean up the server:
```bash
sudo rm /etc/nginx/.htpasswd-xamxam
```
### Phase 6 — Admin UI: retire password management page
- Remove or repurpose `app/public/admin/account.php` and
`app/public/admin/actions/account.php`.
- Remove the "Compte" nav link from the admin header.
- The `site_settings` rows `admin_password_hash` can be left in the DB
(harmless) or cleared with a migration.
### Phase 7 — Testing
- [ ] LDAP server reachable from VM (Phase 1 smoke test).
- [ ] Valid staff credentials → session created, redirected to `/admin/`.
- [ ] Invalid password → denied, error shown, no session.
- [ ] Unknown username → denied (same error message — no username enumeration).
- [ ] LDAP server unreachable → denied with "service unavailable", not a
PHP fatal.
- [ ] Group check (if configured): non-member staff → denied.
- [ ] Session expiry / logout → redirected to login form.
- [ ] Brute-force: 20+ rapid login attempts → nginx rate limit kicks in (429).
- [ ] Verify `/etc/nginx/.htpasswd-xamxam` no longer exists on server.
---
## What does NOT change
- The PHP session layer (`AdminAuth::startSession`, `isAuthenticated`,
`logout`, cookie parameters) — untouched.
- The CSRF protection on all action handlers.
- The nginx rate-limiting zone for `/admin/`.
- All other nginx security rules (file blocking, security headers, etc.).
- The `just manage-admin-users` recipe can be removed from the justfile.
---
## Security notes
- **Use LDAPS (port 636) exclusively.** Plain LDAP on port 389 transmits
the user's password in cleartext on the wire. Even on a trusted LAN this
is not acceptable.
- **Service account must be read-only.** It must not have write permission
to any part of the directory.
- **Do not store the service-account password in the repository.** Use
`Database::setSetting()` (already encrypted at rest via filesystem
permissions) or an env variable set in the server environment.
- **Never log the user's password or the service-account password.**
- **Fail closed.** If `ldap_connect` or the service-account bind fails, deny
access. Do not fall back to a local password.
- **Sanitise the username** before using it in the LDAP filter:
escape special characters per RFC 4515 to prevent LDAP injection
(`(uid=*)(|(uid=*))`-style attacks). PHP's `ldap_escape()` with
`LDAP_ESCAPE_FILTER` flag handles this.
-141
View File
@@ -1,141 +0,0 @@
# LDAP Authentication Specification for XAMXAM Admin
## Current state
Two-layer authentication guards the `/admin/` area:
| Layer | Mechanism | Where |
|-------|-----------|-------|
| 1 (nginx) | `auth_basic` against `/etc/nginx/.htpasswd-xamxam` | `nginx/xamxam.conf` |
| 2 (PHP) | `AdminAuth` — bcrypt hash in `site_settings.admin_password_hash` | `app/src/AdminAuth.php`, `app/public/admin/login.php`, `app/public/admin/actions/account.php` |
Layer 1 controls the browser's Basic Auth dialog. Layer 2 provides a PHP session gate and a
fallback login form. When both layers share the same password, the user is authenticated
transparently (nginx passes `PHP_AUTH_PW` to PHP, `AdminAuth` verifies it against the DB hash).
## Goal
Replace both layers with LDAP-based authentication while preserving the defence-in-depth
structure and the transparent user experience (single sign-on via the browser's Basic Auth
dialog, no PHP login form unless fallback).
## Required information from IT
| # | Item | Example / format |
|---|------|------------------|
| 1 | **LDAP server URL** | `ldaps://ldap.erg.be:636` or `ldap://ldap.erg.be:389` |
| 2 | **Base DN** | `dc=erg,dc=be` |
| 3 | **Bind DN** (service / search account) | `cn=svc-xamxam,ou=services,dc=erg,dc=be` |
| 4 | **Bind password** | (secret — read-only account is sufficient) |
| 5 | **User search filter** | `(&(uid=%s)(memberOf=cn=admin-xamxam,ou=groups,dc=erg,dc=be))` — `%s` is the username entered in the Basic Auth dialog |
| 6 | **Group membership mechanism** | `memberOf` attribute (AD-style) **or** `member`/`uniqueMember` on the group entry (OpenLDAP-style) |
| 7 | **Username attribute** | Typically `uid` (OpenLDAP) or `sAMAccountName` (AD). What attribute should the user type in the auth dialog? |
| 8 | **TLS certificate** | If `ldaps://` is used and the certificate is self-signed, provide the CA certificate (PEM). Otherwise confirm it's a publicly-trusted cert. |
| 9 | **Admin group DN/CN** | The exact DN or CN that grants admin access (e.g. `cn=xamxam-admins,ou=groups,dc=erg,dc=be`). If there's no group yet, what should it be named? |
## Architecture
```
Browser Nginx LDAP daemon LDAP server
│ │ │ │
│─ GET /admin/ ──────────►│ │ │
│◄── 401 WWW-Authenticate │ │ │
│─ GET /admin/ + Basic ──►│ │ │
│ │─ POST /auth-ldap ───────────►│ │
│ │ (proxy Authorization hdr) │─ ldap_bind ──────────►│
│ │ │◄── success ───────────│
│ │ │─ ldap_search ────────►│
│ │ │◄── group check OK ────│
│ │◄── 200 OK ──────────────────│ │
│ │─ forward to PHP ────────────► │
│ │ │ │
│◄── admin page ─────────│ │
```
### Option A — `nginx-ldap-auth` daemon (preferred)
- Drop-in replacement for `auth_basic` / `.htpasswd` using nginx's `auth_request` module
- A small Python 3 daemon (`nginx-ldap-auth`) runs at `127.0.0.1:8888`
- Configured via `/etc/nginx-ldap-auth.conf` (JSON or YAML)
- Nginx proxies the `Authorization` header to the daemon; daemon binds to LDAP,
checks group membership, returns 200 or 403
- **The PHP `AdminAuth` layer remains** — it receives `PHP_AUTH_PW` from nginx,
can verify the username against LDAP group membership, and establish the PHP session
Nginx config (add to `location ^~ /admin/`):
```nginx
location ^~ /admin/ {
# Replace auth_basic + auth_basic_user_file with:
auth_request /auth-ldap;
auth_request_set $saved_set_cookie $upstream_http_set_cookie;
add_header Set-Cookie $saved_set_cookie;
# Client-facing Basic Auth challenge (so the browser asks for credentials)
satisfy any;
# Fallback: if auth_request returns 401, challenge
error_page 401 = @ldap_challenge;
# Keep: rate limiting, CSP, PHP handling, security headers
limit_req zone=admin burst=20 nodelay;
# ... rest as-is ...
}
# Internal endpoint — delegates to LDAP daemon
location = /auth-ldap {
internal;
proxy_pass http://127.0.0.1:8888;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Authorization $http_authorization;
}
# Trigger browser Basic Auth dialog when LDAP returns 401
location @ldap_challenge {
add_header WWW-Authenticate 'Basic realm="Admin Access - XAMXAM"';
return 401;
}
```
### Option B — `ngx_http_auth_ldap_module` (native nginx module)
- Requires recompiling nginx with this third-party module
- Simpler config: `auth_ldap "XAMXAM Admin"; auth_ldap_servers { ... }`
- Less flexible; harder to debug
### Option C — PHP-only LDAP (no nginx layer)
- Remove nginx auth entirely
- `AdminAuth::requireLogin()` does `ldap_bind()` + group check directly in PHP
- Simpler nginx config, but no nginx-level gate
- Browser auth dialog still possible via PHP sending `WWW-Authenticate` header
## After LDAP is working: cleanup checklist
| Step | File(s) affected | Action |
|------|-----------------|--------|
| 1 | `app/src/AdminAuth.php` | Remove `getStoredHash()`, `setPasswordHash()`, `removePasswordHash()`, `hasPassword()`, `verifyHash()`. Keep `requireLogin()`, `isAuthenticated()`, `login()`, `logout()` — adapt them to LDAP group check. |
| 2 | `app/public/admin/login.php` | Remove entirely (no more PHP login form). |
| 3 | `app/public/admin/actions/account.php` | Remove entirely (no more password CRUD). |
| 4 | `app/templates/admin/login.php` | Remove template file. |
| 5 | `app/templates/admin/parametres.php` | Remove the "Compte administrateur" `<section>` (password set/change/delete UI). |
| 6 | `app/public/admin/parametres.php` | Remove `AdminAuth::hasPassword()` call and related variables. |
| 7 | `app/templates/admin/account.php` | Remove if only used for password management. |
| 8 | `nginx/xamxam.conf` | Remove `auth_basic` and `auth_basic_user_file` lines from the admin location block. |
| 9 | Database | Remove `admin_password_hash` row from `site_settings` table (manual or migration). |
| 10 | `app/bootstrap.php` | Remove legacy `ADMIN_PASSWORD_HASH` constant reference if present. |
## Dependencies to install
- **Option A**: Python 3, `python3-ldap` (or `pip install python-ldap`), `nginx-ldap-auth` daemon
- **Option B**: nginx recompiled with `ngx_http_auth_ldap_module`
- **Option C**: PHP `ldap` extension (`php8.4-ldap` or `apt install php-ldap`)
## Notes
- The `AdminAuth` PHP layer should remain even after LDAP is implemented — it provides
session persistence, logout, CSRF integration, and the admin audit log identity.
- The LDAP daemon/nginx layer handles **authentication** (who are you?).
The PHP `AdminAuth` layer handles **session management** (are you still you?).
- If IT provides a dedicated admin group, access control is centralised: adding/removing
an admin is a single LDAP operation, no need to touch the server.
+31 -26
View File
@@ -1,7 +1,7 @@
# Documentation index # Documentation index
This directory mixes **current reference** docs, **proposals/plans**, and This directory separates **current reference** docs, **proposals/plans**, and
**historical/archived** analysis. Use this index to find the right document. **historical/archived** analysis (in [`archive/`](archive/)).
> **Note on naming:** XAMXAM was previously *Post-ERG* (and the code was once > **Note on naming:** XAMXAM was previously *Post-ERG* (and the code was once
> organised under `posterg-website/`, `apps/`, `front-backend/`, > organised under `posterg-website/`, `apps/`, `front-backend/`,
@@ -31,7 +31,7 @@ This directory mixes **current reference** docs, **proposals/plans**, and
| Doc | Status | | Doc | Status |
|-----|--------| |-----|--------|
| [LDAP_AUTH_PLAN.md](LDAP_AUTH_PLAN.md) + [LDAP_SPEC.md](LDAP_SPEC.md) | LDAP login — **not implemented** | | [ldap.md](ldap.md) | LDAP login — **not implemented** (merged plan + spec) |
| [monolog-plan.md](monolog-plan.md) | Single Monolog logger replacing AppLogger/AdminLogger/ErrorHandler/Audit — **plan** | | [monolog-plan.md](monolog-plan.md) | Single Monolog logger replacing AppLogger/AdminLogger/ErrorHandler/Audit — **plan** |
| [de-librairisation.md](de-librairisation.md) | Replace bespoke SMTP/Markdown/HTTP/crypto with libraries (partly done) | | [de-librairisation.md](de-librairisation.md) | Replace bespoke SMTP/Markdown/HTTP/crypto with libraries (partly done) |
| [refactoring.md](refactoring.md) | Older refactoring proposal | | [refactoring.md](refactoring.md) | Older refactoring proposal |
@@ -39,31 +39,36 @@ This directory mixes **current reference** docs, **proposals/plans**, and
| [ANALYSIS_INLINE_JS_CSS_MINIFY.md](ANALYSIS_INLINE_JS_CSS_MINIFY.md) | Inline JS/CSS/minify analysis (largely actioned by the build system) | | [ANALYSIS_INLINE_JS_CSS_MINIFY.md](ANALYSIS_INLINE_JS_CSS_MINIFY.md) | Inline JS/CSS/minify analysis (largely actioned by the build system) |
| [backup-plan.md](backup-plan.md) | Backup plan — largely implemented (see deployment.md) | | [backup-plan.md](backup-plan.md) | Backup plan — largely implemented (see deployment.md) |
| [repertoire-mobile-propositions.md](repertoire-mobile-propositions.md) | Mobile repertoire UI proposals | | [repertoire-mobile-propositions.md](repertoire-mobile-propositions.md) | Mobile repertoire UI proposals |
| [cms-migration-plan.html](cms-migration-plan.html) | CMS migration plan | | [spec-sheet.md](spec-sheet.md) | Original requirements fiche technique (incl. client notes appendix) |
| [spec-sheet.md](spec-sheet.md) | Original requirements fiche technique |
| `Proposition procédure licences_V2.pdf` | Licence procedure proposal (filename contains non-ASCII/combining chars) |
## Historical / archived (kept for context — may reference the old codebase) ## Active investigations (unresolved)
| Doc | Status |
|-----|--------|
| [filepond-crash-analysis.md](filepond-crash-analysis.md) | FilePond upload crash — **unresolved**, root cause in vendor code (merged race-investigation appendix) |
| [peertube-sso-incident.md](peertube-sso-incident.md) | PeerTube `invalid_grant` diagnosis & ownership |
| [autosave-system.md](autosave-system.md) | Autosave architecture & HTMX migration assessment |
## Historical / archived (in [`archive/`](archive/))
Kept for context — may reference the old codebase. Not maintained.
| Doc | Contents | | Doc | Contents |
|-----|----------| |-----|----------|
| [LIVRAISONS_PAR_MOIS.md](LIVRAISONS_PAR_MOIS.md) | Monthly delivery log grouped by functional family | | `vm-crash-incident.md` | VM crash root-cause (merged final report + reports + evidence + fix) |
| [migration-history.md](migration-history.md) | History of major structural migrations | | `CURRENT_ISSUES.md` | Issue log (2026-05-10) — many since resolved |
| [CURRENT_ISSUES.md](CURRENT_ISSUES.md) | Issue log (2026-05-10) — many since resolved | | `LIVRAISONS_PAR_MOIS.md` | Monthly delivery log grouped by functional family |
| [IMMEDIATE_FIX.md](IMMEDIATE_FIX.md) | One-off fix note | | `migration-history.md` | History of major structural migrations |
| [EVIDENCE_SUMMARY.md](EVIDENCE_SUMMARY.md) + [VM_Crash_*.md](VM_Crash_Analysis_FINAL.md) | VM crash investigation (concluded: not the app) | | `php-vs-flask.md` | Language choice decision |
| [css.md](css.md) | Old Bulma-removal writeup (pre-dates current build system; see CSS.md) | | `orm-assessment.md` | ORM evaluation |
| [php-vs-flask.md](php-vs-flask.md) | Language choice decision | | `css.md` | Old Bulma-removal writeup (see CSS.md for current) |
| [orm-assessment.md](orm-assessment.md) | ORM evaluation | | `system-setup.md` | PHP-extension inventory |
| [system-setup.md](system-setup.md) | Old setup notes | | `SETUP.md` | Earlier setup snapshot (see development.md) |
| [SETUP.md](SETUP.md), [SPECS.md](SPECS.md), [TODO.md](TODO.md) | Earlier setup/spec/todo snapshots | | `testing.md` | PHP testing best-practices writeup |
| [SMTP_550_POSTFIX_FIX.md](SMTP_550_POSTFIX_FIX.md) | SMTP troubleshooting record | | `SMTP_550_POSTFIX_FIX.md` | SMTP troubleshooting record |
| [testing.md](testing.md) | PHP testing best-practices writeup | | `cms-migration-plan.html` | CMS migration plan |
| [test-plan.md](test-plan.md) | Manual test plan | | `Proposition procédure licences_V2.pdf` | Licence procedure proposal |
| [autosave-system.md](autosave-system.md), [filepond-crash-analysis.md](filepond-crash-analysis.md), [filepond-race-investigation.md](filepond-race-investigation.md) | Feature/issue deep-dives (status noted in each file) | | `pi-session-2026-05-10T*.html` | Captured session log |
| [pi-session-2026-05-10T*.html](pi-session-2026-05-10T18-42-37-234Z_019e1332-ce31-70fa-87a1-aa3495b526a9.html) | Captured session log |
| [ANALYSIS_INLINE_JS_CSS_MINIFY.md](ANALYSIS_INLINE_JS_CSS_MINIFY.md) | *(see proposals)* |
| [bookmarklet.md](bookmarklet.md) | *(kept current — see above)* |
## Related documentation elsewhere ## Related documentation elsewhere
@@ -74,5 +79,5 @@ This directory mixes **current reference** docs, **proposals/plans**, and
--- ---
**Maintenance guidance:** when updating code, update the matching *current **Maintenance guidance:** when updating code, update the matching *current
reference* doc in the table above. Leave *historical* docs untouched (they are reference* doc in the table above. Leave *archived* docs untouched (read-only
read-only context). Move newly-written analysis into the appropriate section. context). Move newly-written analysis into the appropriate section.
-36
View File
@@ -1,36 +0,0 @@
- l'ordre des TFE sur la page d'accueil ; est-ce que ce serait possible de les faire dérouler par année – avec les plus récents tout en haut –, mais en rendant l'ordre de chaque année aléatoire
- On a les pdf et notes d'intention des dernières années, est-ce que vous voulez déjà y avoir accès ? Est-ce qu'il y a une nomenclature particulière qui vous fait plaisir ?
## admin
- Il a été décidé de – pour l’instant – ne pas rendre les TFE visibles vers l’extérieur.
- option d’ouverture “interne” – qui sera à priori le défaut appliqué sur une majorité des TFE –
pas disponnible:
- le pdf ainsi que la note d’intention ne soient que disponibles quand les personnes se trouvent physiquement à l’erg (via adresse IP) ou via login (à voir ce qui est le plus simple à intégrer techniquement).
## le formulaire de dépôt
- On demandera aux étudiant·es de préparer une image au bon format pour le dépôt du TFE. Est-ce que vous pouvez nous donner la taille qu'il faudrait ?
- la page de formulaire:celle que les étudiant·es doivent remplir lors du dépôt du TFE, ajouter:
- l'explication;
- le contexte des différents choix soient visibles.
- Quand un·é étudiant·e dépose son TFE, il ne doit pas être publié directement. Il doit arriver dans la base de donnée, et quelqu'un viendrait juste clicker sur “publier” dans le backoffice une fois la défense orale terminée (et en fonction du retour du jury).
- l’option “libre” ne doit donc pas encore exister cette année.
- créer un système de toggle pour quelles sont les options actives dans le formulaire
- Il n’y a pour l’instant que l’option “interdit” et “interne”. L’option “libre” ne sera activée que à partir de l’année académique prochaine.
- la case “contact” soit accompagnée d’une case à cocher/décocher ; « Je veux que mon contact soit accessible à toustes depuis la plateforme xamxam ». En fonction de cette réponse, le contact apparaîtrait ou non sur la page du TFE.
## la base de donnée
- rajouter une catégorie “objet” pour que, dans un futur éventuel, on puisse différencier les TFE des FRART et des thèses. Pour l’instant c’est juste un tag qui doit apparaître en back-office.
- quelle(s) fonte(s) est-ce que vous utilisez sur le site ?
-ce que vous pouvez m’envoyez un export de la maquette du site ? (en .jpg c’est ok, c’est juste pour rafraîchir nos mémoires afin qu'on puisse produire les textes en adéquation avec ce qui existe)
-305
View File
@@ -1,305 +0,0 @@
# VM Crash Root Cause Analysis - FINAL REPORT
**Date:** 2026-03-26
**Server:** posterg.erg.be
**Investigation Status:** ✅ **ROOT CAUSE IDENTIFIED**
---
## 🔥 ROOT CAUSE: Serial Console (serial-getty) Crash Loop
### The Smoking Gun
**The VM did NOT crash due to the nginx/posterg application.**
The crash was caused by a **systemd serial-getty service crash loop** that ran continuously for ~50 days, eventually exhausting system memory.
### Evidence
1. **1,264,488 serial-getty crashes** recorded in the journal
2. **Restart counter reached 421,491** by the time of OOM event
3. **Crashed every 10 seconds** for the entire uptime
4. **Error message:** `agetty[PID]: could not get terminal name: -22` / `failed to get terminal attributes: Input/output error`
### Timeline Reconstruction
| Date | Event | Details |
|------|-------|---------|
| **Jan 13, 2026** | System boot | Clean boot, services started normally |
| **Jan 13 - Mar 4** | Serial getty crash loop begins | ~421,491 restarts over 48.7 days (6 restarts/min) |
| **Mar 4, 10:45** | MariaDB memory pressure | InnoDB reports memory pressure event |
| **Mar 4, 10:50** | OOM Killer triggered | Systemd invokes OOM killer due to memory exhaustion |
| **Mar 4, 10:51** | Journal stops | System likely became unresponsive |
| **Mar 4 - Mar 24** | Unknown state | Gap in logs (20 days) - system may have limped along or was frozen |
| **Mar 24, 12:56** | Hard reboot | Technicians forced reboot |
| **Mar 24, 12:57** | System back online | New boot, clean state |
### Why This Happened
**QEMU/KVM Virtual Machine Configuration Issue**
The error `could not get terminal name: -22` (EINVAL) indicates that the virtual machine's serial console (ttyS0) is **misconfigured or not properly connected** at the hypervisor level.
**Common causes:**
- Serial console enabled in VM config but not attached to host
- QEMU `-serial` parameter misconfigured
- VirtIO console driver issue
- Host-side serial device permissions
### Resource Impact
Each `agetty` process spawn:
- Creates a new process (PID allocation, memory for process struct)
- Opens file descriptors
- Logs to journal (1,264,488 log entries × ~200 bytes = **~240MB journal bloat**)
- Accumulates systemd tracking overhead
Over 50 days with 6 crashes/minute:
- **~421,000 failed process spawns**
- **~1.2 million journal entries**
- **Gradually consumed available memory**
- **Eventually triggered OOM killer**
---
## 🔍 What About the Posterg Application?
### Application is NOT at Fault
**Evidence the application is innocent:**
1. **No PHP-FPM crashes** - Service ran cleanly with normal memory usage (11.1-11.2M peak)
2. **No nginx errors** before the OOM - The 234KB error log is from **after the reboot** (Mar 26), mostly security scanner attempts
3. **Normal traffic patterns** - Only internal IP (192.168.6.11) accessing the site
4. **No database issues** before crash - SQLite was working fine
### Post-Reboot Issues (Unrelated to Crash)
**After the March 24 reboot**, there WERE application errors:
```
SQLSTATE[HY000]: General error: 1 no such table: tags
SQLSTATE[HY000]: General error: 1 no such column: ts.role
```
These are **database schema migration issues** from code changes, NOT the crash cause:
- Code was updated on Mar 24 14:49 (after reboot)
- Database schema wasn't migrated properly
- Missing `tags` table and `ts.role` column
### Post-Reboot Security Events (Mar 26)
**955 blocked requests** from 192.168.6.11:
- `.env` file probes
- `.git/config` attempts
- WordPress scanner attacks
- Next.js/Nuxt.js config file probes
**All properly blocked by nginx rules** - Working as designed ✅
---
## 🛠️ The Fix
### Immediate Action Required
**Disable the serial-getty service:**
```bash
sudo systemctl stop serial-getty@ttyS0.service
sudo systemctl disable serial-getty@ttyS0.service
sudo systemctl mask serial-getty@ttyS0.service
```
This will prevent the crash loop from reoccurring.
### Verify the Fix
```bash
# Confirm service is masked
sudo systemctl status serial-getty@ttyS0.service
# Should show: "Loaded: masked"
```
### Optional: Fix the Console Properly
If you need serial console access (for emergency recovery), configure it properly:
**On the hypervisor/host machine:**
1. **For QEMU/KVM VMs:**
```bash
# Edit VM XML configuration
virsh edit posterg
# Add or verify serial console configuration:
<serial type='pty'>
<target type='isa-serial' port='0'>
<model name='isa-serial'/>
</target>
</serial>
<console type='pty'>
<target type='serial' port='0'/>
</console>
```
2. **Restart the VM** (planned maintenance window)
3. **Re-enable serial-getty:**
```bash
sudo systemctl unmask serial-getty@ttyS0.service
sudo systemctl enable serial-getty@ttyS0.service
sudo systemctl start serial-getty@ttyS0.service
```
---
## 📊 System Health Analysis
### Current State (Post-Reboot)
✅ **All systems healthy:**
- Memory: 7.8GB total, 464MB used (6% usage)
- Disk: 30GB, 3.2GB used (12% usage)
- Swap: 976MB, unused
- Load: 0.00 (idle)
- nginx: 4 workers running
- PHP-FPM: 2 workers running
- MariaDB: 155MB RSS (normal)
### No Application-Level Issues
The posterg application:
- Has sensible rate limiting (though could be tighter)
- Blocks malicious requests properly
- Has reasonable resource limits
- Shows no signs of memory leaks or bugs
---
## 🎯 Recommendations
### 1. **CRITICAL: Disable serial-getty** (See "The Fix" section above)
### 2. **Fix Database Schema** (Post-reboot issues)
The application has schema migration errors:
```bash
# On the server
cd /var/www/posterg
ls storage/migrations/
# Apply missing migrations or rebuild schema
sqlite3 storage/posterg.db < storage/schema.sql
```
### 3. **Improve Monitoring** (Prevent future surprises)
```bash
# Install basic monitoring
sudo apt install prometheus-node-exporter
# Add systemd unit monitoring
# This would have alerted you to serial-getty crashes
```
### 4. **Journal Maintenance** (Clean up bloat)
```bash
# Check journal size
sudo journalctl --disk-usage
# Limit journal size
sudo journalctl --vacuum-size=500M
sudo journalctl --vacuum-time=30d
# Configure permanent limits in /etc/systemd/journald.conf:
SystemMaxUse=500M
SystemKeepFree=1G
MaxRetentionSec=30day
```
### 5. **Optional: Tighten Security** (Nice-to-have)
The nginx config is already good, but you could:
```nginx
# Reduce rate limits further
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/m; # Was 30r/m
limit_req_zone $binary_remote_addr zone=search:10m rate=5r/m; # Was 30r/m
# Add fail2ban for repeated 403s
# Install: sudo apt install fail2ban
```
---
## 📝 Summary for Management
**What happened:**
- VM became unresponsive on March 4, requiring a reboot on March 24
- Root cause: Misconfigured serial console service crashed 421,491 times over 50 days
- Eventually exhausted system memory and triggered OOM killer
**Was it the website's fault?**
- **NO** - The posterg application performed normally
- PHP, nginx, and database all operated within normal parameters
- No application bugs or memory leaks detected
**What needs to be done:**
1. Disable the broken serial-getty service (5 minutes, zero downtime)
2. Fix database schema migrations for post-reboot errors (10 minutes)
3. Optional: Configure journal size limits (5 minutes)
4. Optional: Fix serial console properly at hypervisor level (requires maintenance window)
**Will it happen again?**
- **NO** - Once serial-getty is disabled, this specific issue cannot recur
- The website application can continue running indefinitely
**Risk level:**
- Before fix: 🔴 HIGH - Will crash again in ~50 days
- After fix: 🟢 LOW - Normal operation expected
---
## 📎 Appendix: Technical Details
### OOM Event Details
```
Mar 04 10:50:23 posterg kernel: systemd invoked oom-killer
gfp_mask=0x140cca(GFP_HIGHUSER_MOVABLE|__GFP_COMP), order=0
```
**What this means:**
- System tried to allocate a memory page
- No free memory available
- OOM killer invoked to free memory by killing a process
### Serial Getty Error Code
```
agetty[PID]: could not get terminal name: -22
```
**Error -22 = EINVAL:**
- Invalid argument passed to terminal initialization
- Serial device (ttyS0) not properly configured
- Likely misconfigured at QEMU/KVM level
### Journal Statistics
```
Total journal entries: ~193 MB
Serial-getty crashes: 1,264,488 entries (~65% of journal)
Actual uptime: ~50 days (Jan 13 - Mar 4)
Crash frequency: Every 10 seconds
Total restarts: 421,491
```
---
**Report prepared by:** Automated Analysis + Human Review
**Confidence level:** 🟢 HIGH (Root cause definitively identified)
**Validation status:** ✅ Evidence-backed from kernel logs, journal, and service logs
-416
View File
@@ -1,416 +0,0 @@
# VM Crash Investigation Report - posterg.erg.be
**Date:** 2026-03-26
**Investigator:** Automated Investigation (Limited Access)
**Server:** theophile@posterg.erg.be:3274
## Executive Summary
The VM experienced an unresponsive state requiring a hard reboot on **March 24, 2026 at ~12:56 UTC**. Investigation was limited by lack of root/adm group access to critical system logs. Initial findings show no obvious application-level issues, but **critical system logs require root access for complete analysis**.
---
## Timeline
### Confirmed Events
- **Last known activity (previous boot):** March 2, 2026 15:38:59 UTC
- **Gap period:** March 2-24 (22 days) - **NO BOOT LOGS AVAILABLE**
- **System reboot:** March 24, 2026 12:56-12:57 UTC
- **Current uptime:** 2 days, 1 hour (as of investigation time)
- **Current system state:** Stable, all services running normally
### Critical Unknown
**There is a 22-day gap in boot records** between March 2 and March 24. This could indicate:
1. System was running continuously during this period and crashed on/before March 24
2. Multiple unrecorded reboots occurred
3. Journal corruption or rotation issues
---
## What I Could Access (Non-Root Investigation)
### ✅ Successfully Checked
#### 1. Current System Health
```
Memory: 7.8GB total, 464MB used, 5.9GB free (HEALTHY)
Disk: 30GB, 3.2GB used (12% usage - HEALTHY)
Swap: 976MB, 0B used (not being used)
Load Average: 0.00, 0.00, 0.00 (IDLE)
```
#### 2. Running Services
- **nginx:** 4 worker processes running normally
- **php-fpm:** Master + 2 workers (PHP 8.4)
- **mariadb:** Running (155MB RSS)
- All services appear healthy with normal memory usage
#### 3. Nginx Configuration Analysis
**Location:** `/etc/nginx/sites-available/posterg`
**Security Measures Found:**
- Rate limiting configured:
- General requests: 30 req/min
- Search endpoint: 30 req/min (burst=10)
- Admin: 60 req/min (burst=20)
- Client max body size: 100MB
- Timeouts: 120 seconds (read/send)
- HTTP Basic Auth on `/admin/` directory
**Potential Issues:**
- ⚠️ Rate limits are relatively **permissive** (30 req/min could allow rapid resource consumption)
- ⚠️ Large upload size (100MB) combined with multiple concurrent uploads could **exhaust memory**
- ⚠️ 120-second timeouts on PHP processing could lead to **worker process accumulation**
#### 4. Application Architecture
**Type:** PHP-based thesis repository
**Database:** SQLite (located in `/var/www/posterg/storage/posterg.db`)
**Framework:** Custom PHP with:
- Database.php (SQLite handler)
- AdminAuth.php (authentication)
- RateLimit.php (custom rate limiting)
- Parsedown.php (markdown parser - 52KB, could be memory-intensive)
**Endpoints:**
- Public: index, search, thesis view (tfe.php), media, licenses
- Admin: CRUD operations, import, logs, maintenance mode
- File uploads: Media files and thesis PDFs
#### 5. Log File Status
**Nginx Access Logs:**
- Current: `posterg_access.log` (133KB)
- Last rotation: March 25, 2026 15:47
**Nginx Error Logs:**
- Current: `posterg_error.log` (234KB) ⚠️ **LARGE SIZE**
- Previous: `posterg_error.log.1` (732B - from Mar 25)
**Critical:** Error log grew from 732B to 234KB in ~1 day. **This suggests recent error activity.**
---
## What I CANNOT Access (Requires Root/Sudo)
### 🔒 Blocked Investigations
#### 1. **Nginx Error Logs** ❌
```bash
Permission denied: /var/log/nginx/posterg_error.log
```
**WHY CRITICAL:** This 234KB error log likely contains the root cause. Typical error logs are <10KB.
**Commands to run (as root):**
```bash
# View recent errors before crash
sudo tail -1000 /var/log/nginx/posterg_error.log
# Check for PHP-FPM errors, memory exhaustion, timeouts
sudo grep -E "memory|exhausted|timeout|fatal|error" /var/log/nginx/posterg_error.log
# Look for patterns (repeated errors from specific IP/endpoint)
sudo awk '{print $1}' /var/log/nginx/posterg_error.log | sort | uniq -c | sort -rn | head -20
```
#### 2. **System Journal Logs** ❌
```bash
journalctl: Users in groups 'adm', 'systemd-journal' can see all messages
```
**WHY CRITICAL:** Contains kernel messages, OOM killer events, service crashes, and the exact crash reason.
**Commands to run (as root):**
```bash
# Check last boot messages for crash indicators
sudo journalctl -b -1 --no-pager | grep -E "Out of memory|OOM|killed|panic|segfault"
# View kernel messages around crash time
sudo journalctl -k -b -1 --since "2026-03-24 12:00" --until "2026-03-24 13:00"
# Check for PHP-FPM/nginx crashes
sudo journalctl -u php8.4-fpm -b -1 --since "2026-03-24 11:00"
sudo journalctl -u nginx -b -1 --since "2026-03-24 11:00"
# Look for repeated service restarts
sudo journalctl -b -1 | grep -E "Started|Stopped|Failed" | tail -100
```
#### 3. **Kernel Messages (dmesg)** ❌
```bash
dmesg: Operation not permitted
```
**WHY CRITICAL:** Shows hardware errors, OOM kills, kernel panics, disk issues.
**Commands to run (as root):**
```bash
# Check for OOM killer activity
sudo dmesg -T | grep -i "out of memory"
# Check for hardware/disk errors
sudo dmesg -T | grep -i "error\|fail\|critical"
# Review last 200 kernel messages
sudo dmesg -T | tail -200
```
#### 4. **PHP-FPM Logs** ❌
```bash
Permission denied: /var/log/php8.4-fpm.log
```
**WHY CRITICAL:** Shows PHP memory exhaustion, fatal errors, slow requests.
**Commands to run (as root):**
```bash
# Check for PHP memory errors
sudo grep -E "memory|fatal|error|segfault" /var/log/php8.4-fpm.log*
# Look for slow request logs
sudo find /var/log -name "*php*slow*" -exec cat {} \;
```
#### 5. **System Logs Archive**
**Location:** `/var/log/journal/9a57a2432f96427a80e97d1d269e6a58/`
Contains binary journal files from previous boots but **not readable without root**.
---
## Hypotheses (Ranked by Likelihood)
### 1. 🔥 **Memory Exhaustion / OOM Killer** (HIGH PROBABILITY)
**Evidence:**
- Large 100MB upload limit
- Multiple PHP-FPM workers could accumulate
- 234KB error log suggests many errors occurred
- System became completely unresponsive (classic OOM symptom)
**Attack Vectors:**
- Multiple concurrent large file uploads (thesis PDFs)
- Search endpoint abuse despite rate limiting
- SQLite database operations on large datasets
- Parsedown.php processing large markdown files
**How to Confirm:**
```bash
# Check for OOM killer evidence
sudo journalctl -b -1 | grep -i "oom"
sudo dmesg -T | grep -i "killed process"
sudo grep "Out of memory" /var/log/syslog* 2>/dev/null
```
### 2. ⚠️ **PHP-FPM Process Accumulation** (MEDIUM PROBABILITY)
**Evidence:**
- 120-second timeout allows long-running requests
- Slow SQLite queries could pile up
- If workers get stuck, new connections queue
**How to Confirm:**
```bash
# Check PHP-FPM configuration
cat /etc/php/8.4/fpm/pool.d/www.conf | grep -E "pm\.max_children|pm\.start_servers|pm\.min_spare|pm\.max_spare"
# Review PHP-FPM slow log
sudo cat /var/log/php8.4-fpm-slow.log 2>/dev/null
```
### 3. ⚡ **Database Lock Contention** (MEDIUM PROBABILITY)
**Evidence:**
- SQLite with multiple concurrent writers
- Admin import operations + public searches simultaneously
- SQLite has limited concurrency (write locks entire database)
**How to Confirm:**
```bash
# Check error logs for "database is locked" messages
sudo grep -i "database.*lock" /var/log/nginx/posterg_error.log
# Check SQLite journal files (abandoned transactions)
ls -la /var/www/posterg/storage/*.db-journal 2>/dev/null
```
### 4. 🌐 **Brute Force / DDoS Attack** (LOW-MEDIUM PROBABILITY)
**Evidence:**
- Rate limiting exists but is permissive (30 req/min = 1 every 2 seconds)
- Admin panel with HTTP Basic Auth (target for brute force)
- Public search endpoint
**How to Confirm:**
```bash
# Check for attack patterns in access logs
sudo zcat /var/log/nginx/posterg_access.log*.gz | \
awk '{print $1}' | sort | uniq -c | sort -rn | head -20
# Look for 401/403 patterns (brute force attempts)
sudo grep -E " (401|403) " /var/log/nginx/posterg_access.log* | \
awk '{print $1}' | sort | uniq -c | sort -rn
# Check for high request rates
sudo awk '{print $4}' /var/log/nginx/posterg_access.log | cut -d: -f1-2 | \
uniq -c | sort -rn | head -20
```
### 5. 🐛 **Application Bug** (LOW PROBABILITY)
**Evidence:**
- Database.php recently updated (Mar 24 14:49)
- 234KB error log indicates errors occurred
**How to Confirm:**
```bash
# Review nginx errors for PHP fatal errors
sudo grep "PHP Fatal" /var/log/nginx/posterg_error.log
# Check for infinite loops or memory leaks
sudo grep -E "Maximum execution time|memory limit" /var/log/nginx/posterg_error.log
```
---
## Recommended Investigation Steps (For Root User)
### Phase 1: Immediate Analysis (5 minutes)
```bash
# 1. Check the smoking gun - nginx error log
sudo tail -500 /var/log/nginx/posterg_error.log | less
# 2. Look for OOM killer
sudo journalctl -b -1 | grep -i "oom\|killed" | tail -50
# 3. Check journal around crash time
sudo journalctl -b -1 --since "2026-03-24 12:00" --until "2026-03-24 13:00" | less
```
### Phase 2: Deeper Analysis (15 minutes)
```bash
# 4. Export last boot journal to file for analysis
sudo journalctl -b -1 --no-pager > /tmp/last_boot_journal.log
chown theophile:theophile /tmp/last_boot_journal.log
# 5. Check PHP-FPM errors
sudo cat /var/log/php8.4-fpm.log* | grep -E "NOTICE|WARNING|ERROR"
# 6. Analyze access patterns before crash
sudo zcat /var/log/nginx/posterg_access.log*.gz 2>/dev/null | \
awk '$4 >= "[24/Mar/2026:11:00:" && $4 <= "[24/Mar/2026:13:00:"' | \
awk '{print $1}' | sort | uniq -c | sort -rn > /tmp/crash_access_analysis.txt
# 7. Check for database corruption
sqlite3 /var/www/posterg/storage/posterg.db "PRAGMA integrity_check;"
```
### Phase 3: System Health Check (10 minutes)
```bash
# 8. Review PHP-FPM pool configuration
cat /etc/php/8.4/fpm/pool.d/www.conf | grep -v "^;" | grep -v "^$"
# 9. Check system resource limits
ulimit -a
# 10. Review systemd service limits
systemctl show php8.4-fpm | grep -E "LimitNOFILE|LimitNPROC|MemoryLimit"
systemctl show nginx | grep -E "LimitNOFILE|LimitNPROC|MemoryLimit"
```
---
## Preventive Measures to Implement
### Immediate (Before Next Investigation)
1. **Add user to adm group** for log access:
```bash
sudo usermod -aG adm theophile
sudo usermod -aG systemd-journal theophile
```
2. **Enable detailed error logging** (temporarily):
```bash
# In /etc/nginx/sites-available/posterg
error_log /var/log/nginx/posterg_error.log debug;
sudo systemctl reload nginx
```
3. **Enable PHP-FPM slow log:**
```bash
# In /etc/php/8.4/fpm/pool.d/www.conf
slowlog = /var/log/php8.4-fpm-slow.log
request_slowlog_timeout = 10s
sudo systemctl restart php8.4-fpm
```
### Short-term (This Week)
1. **Tighten rate limits** in nginx config:
```nginx
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/m; # Was 30r/m
limit_req_zone $binary_remote_addr zone=search:10m rate=5r/m; # Was 30r/m
```
2. **Add connection limits:**
```nginx
limit_conn_zone $binary_remote_addr zone=addr:10m;
limit_conn addr 10; # Max 10 concurrent connections per IP
```
3. **Reduce PHP-FPM timeout:**
```nginx
fastcgi_read_timeout 60; # Was 120
```
4. **Monitor memory usage:**
```bash
# Add to crontab
*/5 * * * * free -m >> /var/log/memory-monitor.log
```
### Long-term (This Month)
1. **Migrate from SQLite to PostgreSQL/MySQL** for better concurrency
2. **Implement application-level logging** (not just nginx/PHP-FPM)
3. **Add monitoring:** Prometheus + Grafana or similar
4. **Configure log rotation** more aggressively
5. **Set up automated alerts** for high memory/CPU usage
---
## Files to Review (When Root Access Available)
### Priority 1 (Most Likely to Show Cause)
- [ ] `/var/log/nginx/posterg_error.log` (234KB - abnormally large)
- [ ] Journal logs for boot -1: `journalctl -b -1`
- [ ] Kernel messages: `dmesg -T`
### Priority 2 (Supporting Evidence)
- [ ] `/var/log/php8.4-fpm.log*`
- [ ] `/var/log/nginx/posterg_access.log*` (attack pattern analysis)
- [ ] Systemd service logs: `journalctl -u php8.4-fpm -b -1`, `journalctl -u nginx -b -1`
### Priority 3 (Configuration Review)
- [ ] `/etc/php/8.4/fpm/pool.d/www.conf` (worker limits, timeouts)
- [ ] `/etc/security/limits.conf` (system resource limits)
- [ ] `/etc/systemd/system/php8.4-fpm.service.d/` (service overrides)
---
## Questions to Answer
1. **What filled the 234KB error log?** (Compare to normal ~1KB size)
2. **Was there an OOM killer event?** (Check journalctl and dmesg)
3. **What happened between March 2-24?** (22-day boot gap is suspicious)
4. **Were there repeated service crashes/restarts?** (Check systemd journals)
5. **What was the last request before the crash?** (Check nginx access logs)
6. **Is there evidence of an attack?** (IP analysis, rate limit hits)
---
## Next Steps
**For theophile (with sudo access):**
1. Run Phase 1 commands immediately
2. Export journal logs to `/tmp/` for detailed review
3. Review nginx error log and identify patterns
4. Share findings from logs to determine if application is at fault
5. Implement immediate preventive measures (user to adm group, slow logging)
**For automated monitoring (recommended):**
- Set up `fail2ban` for admin panel protection
- Configure `monit` or similar for service health checks
- Enable automatic log forwarding to external system (prevent data loss on crash)
---
**Investigation Status:** ⏸️ PAUSED - Awaiting root access to critical logs
**Risk Level:** 🔴 HIGH - Cause unknown, could recur anytime
**Recommended Priority:** 🚨 URGENT - Next crash could cause data loss
View File
+161
View File
@@ -0,0 +1,161 @@
# VM Crash Root Cause Analysis (posterg.erg.be)
**Date:** 2026-03-26
**Server:** posterg.erg.be
**Status:** ✅ ROOT CAUSE IDENTIFIED — **NOT the application's fault**
> Merged from `VM_Crash_Analysis_FINAL.md`, `VM_Crash_Reports.md`,
> `EVIDENCE_SUMMARY.md`, and `IMMEDIATE_FIX.md` (single incident).
---
## 🔥 ROOT CAUSE: Serial Console (serial-getty) Crash Loop
The VM did **not** crash due to the nginx/posterg application. The crash was
caused by a **systemd `serial-getty@ttyS0` service crash loop** that ran
continuously for ~50 days, eventually exhausting system memory.
### The smoking gun
- **1,264,488 serial-getty crashes** recorded in the journal
- **Restart counter reached 421,491** by the time of the OOM event
- **Crashed every 10 seconds** for the entire uptime
- Error: `agetty[PID]: could not get terminal name: -22` / `failed to get terminal attributes: Input/output error`
### Timeline reconstruction
| Date | Event | Details |
|------|-------|---------|
| Jan 13, 2026 | System boot | Clean boot, services started normally |
| Jan 13 – Mar 4 | Serial getty crash loop | ~421,491 restarts over 48.7 days (6 restarts/min) |
| Mar 4, 10:45 | MariaDB memory pressure | InnoDB reports memory pressure event |
| Mar 4, 10:50 | OOM Killer triggered | Systemd invokes OOM killer due to memory exhaustion |
| Mar 4, 10:51 | Journal stops | System likely became unresponsive |
| Mar 4 – Mar 24 | Unknown state | 20-day gap in logs |
| Mar 24, 12:56 | Hard reboot | Technicians forced reboot |
| Mar 24, 12:57 | System back online | New boot, clean state |
### Why this happened
**QEMU/KVM virtual machine configuration issue.** The error
`could not get terminal name: -22` (EINVAL) indicates the VM's serial console
(ttyS0) is misconfigured or not properly connected at the hypervisor level.
Common causes: serial console enabled in VM config but not attached to host,
QEMU `-serial` parameter misconfigured, VirtIO console driver issue, or
host-side serial device permissions.
### Resource impact
Each `agetty` spawn creates a process, opens file descriptors, and logs to the
journal (~200 bytes per entry). Over 50 days at 6 crashes/minute:
- ~421,000 failed process spawns
- ~1.2 million journal entries (~240MB journal bloat)
- Gradual memory exhaustion → OOM killer
---
## 🔍 The application is NOT at fault
Evidence the posterg application is innocent:
1. **No PHP-FPM crashes** — clean operation, 11.1–11.2M peak memory
2. **No nginx errors before OOM** — the 234KB error log is from *after* the
reboot (Mar 26), mostly blocked security-scanner attempts
3. **Normal traffic** — only internal IP 192.168.6.11 accessing the site
4. **No DB issues before crash** — SQLite working fine
### Post-reboot issues (unrelated to crash)
After the Mar 24 reboot there were schema errors (`no such table: tags`,
`no such column: ts.role`) caused by code updates (Mar 24 14:49) without a
matching migration — **not** the crash cause.
### Post-reboot security events (Mar 26)
955 blocked requests from 192.168.6.11 (`.env`, `.git/config`, WordPress/
Next.js/Nuxt.js probes) — all properly blocked by nginx (working as designed).
---
## 🛠️ The fix
Disable the broken serial console service:
```bash
sudo systemctl stop serial-getty@ttyS0.service
sudo systemctl disable serial-getty@ttyS0.service
sudo systemctl mask serial-getty@ttyS0.service
# Verify
sudo systemctl status serial-getty@ttyS0.service # → "Loaded: masked"
```
**Also fix the post-reboot DB schema errors:**
```bash
cd /var/www/posterg
ls -la storage/migrations/
sqlite3 storage/posterg.db "SELECT name FROM sqlite_master WHERE type='table';"
```
### Optional: fix the serial console properly (hypervisor)
If serial console access is needed for emergency recovery, configure it on the
QEMU/KVM host via `virsh edit posterg` (add/verify `<serial type='pty'>` +
`<console ...>`), restart the VM in a maintenance window, then unmask/re-enable
`serial-getty@ttyS0`.
---
## 📊 Post-reboot system health
✅ All systems healthy — memory 6% used, disk 12% used, swap unused, load idle.
nginx 4 workers, PHP-FPM 2 workers, MariaDB 155MB RSS (all normal).
---
## 🎯 Recommendations
1. **CRITICAL:** disable `serial-getty@ttyS0` (see fix above)
2. **Fix DB schema** for post-reboot errors
3. **Improve monitoring** — `prometheus-node-exporter` or systemd unit monitoring
would have surfaced the serial-getty loop earlier
4. **Journal maintenance:**
```bash
sudo journalctl --disk-usage
sudo journalctl --vacuum-size=500M
sudo journalctl --vacuum-time=30d
# /etc/systemd/journald.conf: SystemMaxUse=500M, SystemKeepFree=1G, MaxRetentionSec=30day
```
5. **Optional:** tighten `limit_req` rates and add `fail2ban` for repeated 403s
---
## 📎 Appendix: technical details
### OOM event
```
Mar 04 10:50:23 posterg kernel: systemd invoked oom-killer
gfp_mask=0x140cca(GFP_HIGHUSER_MOVABLE|__GFP_COMP), order=0
```
### Serial getty error code
`agetty[PID]: could not get terminal name: -22` — EINVAL, terminal
initialization on a misconfigured ttyS0 device.
### Journal statistics
- Total journal entries: ~193 MB
- Serial-getty crashes: 1,264,488 (~65% of journal)
- Uptime at OOM: ~50 days (Jan 13 – Mar 4)
- Crash frequency: every 10s; total restarts 421,491
---
**Report prepared by:** Automated analysis + human review
**Confidence:** 🟢 HIGH (definitively identified from kernel/journal/service logs)
**Risk:** before fix 🟠 HIGH (will recur ~50 days) · after fix 🟢 LOW
+111
View File
@@ -264,3 +264,114 @@ The `destroyFilePondsIn()` function in `file-upload-filepond.js` should abort in
- Added pre-destroy abort in `destroyFilePondsIn()` - Added pre-destroy abort in `destroyFilePondsIn()`
These changes address server response format and cleanup ordering, but **do not bypass the buggy `load-file-error` → `action.status` path inside FilePond's internal code**. The crash still reproduces. These changes address server response format and cleanup ordering, but **do not bypass the buggy `load-file-error` → `action.status` path inside FilePond's internal code**. The crash still reproduces.
---
# HTMX/destroy race investigation (merged from filepond-race-investigation.md)
This section narrows the crash's trigger. It was formerly a separate doc
(`filepond-race-investigation.md`).
## HTMX destroy triggers
The only code path that destroys FilePond instances is `destroyFilePondsIn(el)`, called by the `htmx:beforeSwap` listener:
```js
window.htmx.on("htmx:beforeSwap", onHtmxBeforeSwap);
// → onHtmxBeforeSwap(evt) { destroyFilePondsIn(evt.detail.target); }
```
On the **edit page** (`/admin/edit.php`), the HTMX targets on page load are:
| Element | Trigger | Target selector | Scope |
|---------|---------|-----------------|-------|
| `#toast-region` | `load` | `#toast-region` | Footer `<aside>` |
| `.licence-license-choice` (hidden input) | `load` | `.licence-license-choice` | Licence fieldset |
| Language checkboxes | `change` | `#languages-required-asterisk` | A `<span>` |
| File browser buttons | `click` | `#relink-modal-body` | Modal body |
| Jury autocomplete | `change` | small targets | Form field |
| Tag search input | `input` | pill list container | Form field |
| Licence radio buttons | `change` | `.licence-license-choice` | Licence fieldset |
**None of these targets are ancestors of the `#format-fichiers-block` div**
(which contains all FilePond inputs including the cover queue). Therefore **no
HTMX swap on the edit page can trigger `destroyFilePondsIn` on the FilePond
container during normal operation.**
The `htmx:targetError` in the crash log is confirmed noise: `targetError` does
**not** fire `htmx:beforeSwap`, so no DOM swap occurs.
**Verdict: HTMX does NOT swap the FilePond container. The race hypothesis as stated is refuted.**
## In-flight state at file-pick time
No HTMX request is in flight when the crash occurs: the toast-region's
`hx-get` completes quickly (sub-second, 204 or small fragment) long before a
human clicks "Parcourir" and selects a file. Other triggers require explicit
user interaction; the native file picker is modal and blocks the main thread.
## `znunoqpw` abort analysis
Commit `znunoqpw` added a pre-destroy abort in `destroyFilePondsIn`:
```js
for (var i = 0; i < files.length; i++) {
var f = files[i];
if (f.status === 4 || f.status === 2 || f.status === 3) {
try { pond.removeFile(f); } catch (_abort) {}
}
}
pond.destroy();
```
**The status check is incorrect.** FilePond 4.32.12 internal status constants
are `INIT:1`, `IDLE:2`, `PROCESSING:3`, `PROCESSING_COMPLETE:5`, `LOADING:7`,
`LOAD_ERROR:8`, `PROCESSING_QUEUED:9`. The check catches `2` (IDLE, no-op),
`3` (PROCESSING), and `4` (does not exist). Status `7` (LOADING) is **not
caught**, so a file in the LOAD_FILE filter chain is never removed.
However, `pond.destroy()` → `ABORT_ALL` freezes items and calls `abortLoad()`.
Since `activeLoader` is null during the LOAD_FILE chain, the else branch sets
status INIT + fires `load-abort`; the chain Promise still runs but the freeze
gate (`i.frozen`) suppresses event dispatch. **So the abort mechanism prevents
the crash after destroy, but only when `destroyFilePondsIn` is actually called
— which it never is in the standard repro (HTMX never swaps the container).**
## Line 6878 catch reachability
Two paths dispatch `DID_THROW_ITEM_INVALID` to the `file-status` view writer:
```js
Wt = function(e) {
var t = e.root, n = e.action;
Nt(t.ref.main, n.status.main); // ← crashes if n.status undefined
Nt(t.ref.sub, n.status.sub);
};
```
- **Path A — `load-request-error` → SAFE.** Both branches wrap rejection in
`{ status: { main, sub } }`. No crash.
- **Path B — `load-file-error` → VULNERABLE.** Passes `t.status` through
unguarded. For local files, LOAD_FILE plugins (`FileValidateType`/`FileValidateSize`)
reject with a proper `{ status: { main, sub } }`. For server-loaded files with
error responses, `createResponse` has `.code` not `.status`, but the FileValidateType
filter still wraps its rejection correctly → still safe.
- **`.catch` handler (line 6878)** has an explicit `!t.status` guard → cannot crash.
## Verdict
- **HTMX race hypothesis: REFUTED** (no swap targets the FilePond container;
freeze gate prevents post-destroy dispatch).
- **Actual crash cause: INDETERMINATE (but narrowed).** The only vulnerable path
is `load-file-error` → `DID_THROW_ITEM_INVALID` with `status: undefined`. For
local file selection, the exact path to `undefined` status isn't identified.
The most likely trigger is a **Firefox-specific XHR abort edge case** in the
existing cover file's `server.load`, racing with adding a new local file.
## Recommended next step
Add `console.log` instrumentation to `server.load`'s onload/onerror and a global
`FilePond:error` / `window.error` trap, then reproduce in Firefox. If
`server.load onload` fires immediately before the crash, the race is confirmed
and the fix is **Option B** (custom `fetch`-based `server.load` that never
routes server responses through the LOAD_FILE filter chain).
-278
View File
@@ -1,278 +0,0 @@
# HTMX/destroy race hypothesis — investigation report
## HTMX destroy triggers
### What fires `destroy()` and when, relative to HTMX swap lifecycle
The only code path that destroys FilePond instances is `destroyFilePondsIn(el)`, called by the `htmx:beforeSwap` listener:
```js
window.htmx.on("htmx:beforeSwap", onHtmxBeforeSwap);
// → onHtmxBeforeSwap(evt) { destroyFilePondsIn(evt.detail.target); }
```
On the **edit page** (`/admin/edit.php`), the following HTMX targets exist on page load:
| Element | Trigger | Target selector | Scope |
|---------|---------|-----------------|-------|
| `#toast-region` | `load` | `#toast-region` | Footer `<aside>` |
| `.licence-license-choice` (hidden input) | `load` | `.licence-license-choice` | Inside licence fieldset |
| Language checkboxes | `change` | `#languages-required-asterisk` | A `<span>` |
| File browser buttons | `click` | `#relink-modal-body` | Modal dialog body |
| Jury autocomplete | `change` | various small targets | Form fields |
| Tag search input | `input` | pill list container | Form field |
| Licence radio buttons | `change` | `.licence-license-choice` | Inside licence fieldset |
**None of these targets are ancestors of the `#format-fichiers-block` div,** which contains all FilePond inputs including the cover queue. Every HTMX swap target is either:
- A sibling fieldset (licence), or
- A small DOM fragment inside a fieldset (language asterisk), or
- The toast region in the footer, or
- A modal dialog body
Therefore: **no HTMX swap on the edit page can trigger `destroyFilePondsIn` on the FilePond container during normal operation.**
The `htmx:targetError` in the crash log is confirmed to be unrelated noise. htmx fires `targetError` when a response targets a missing element. On `targetError`, htmx **does not** fire `htmx:beforeSwap` — no DOM swap occurs. The error most likely originates from the toast-region's load-triggered request if the toast region is somehow malformed, or from an internal htmx processing issue unrelated to FilePond.
### Verdict: HTMX does not swap the FilePond container. The race hypothesis as stated is **refuted**.
---
## In-flight state at file-pick time
### Is any HTMX request in flight when the crash occurs?
On the edit page at file-pick time, the toast-region's `hx-get="/admin/toast-fragment.php"` fires on page load but completes quickly (sub-second HTTP request returning 204 when empty, or a small HTML fragment). By the time a human user clicks "Parcourir" and selects a file, this request has long completed.
Other HTMX triggers (`change`, `click`) require explicit user interaction and are not triggered by file selection. The browser's native file picker dialog is modal and blocks the main thread while open, preventing any HTMX polling or background requests from completing during the dialog interaction.
**Conclusion: no HTMX request is in flight when the user selects a file.**
---
## `znunoqpw` abort analysis
### Does the existing abort resolve or reject the filter chain promise?
Commit `znunoqpw` added a pre-destroy abort in `destroyFilePondsIn`:
```js
var files = pond.getFiles();
for (var i = 0; i < files.length; i++) {
var f = files[i];
if (f.status === 4 || f.status === 2 || f.status === 3) {
try { pond.removeFile(f); } catch (_abort) {}
}
}
pond.destroy();
```
**The status check is incorrect.** FilePond 4.32.12 internal status constants are:
- `INIT: 1`, `IDLE: 2`, `PROCESSING: 3`, `PROCESSING_COMPLETE: 5`, `LOADING: 7`, `LOAD_ERROR: 8`, `PROCESSING_QUEUED: 9`
The check `f.status === 4 || f.status === 2 || f.status === 3` catches:
- Status `2` = IDLE (already idle, no-op)
- Status `3` = PROCESSING (upload in progress)
- Status `4` — **does not exist** in FilePond 4.32.12
**Status `7` (LOADING)** — which is the state of a file in the LOAD_FILE filter chain — is **not caught**. Therefore, for a file being loaded (after blob read, during LOAD_FILE validation), `removeFile` is never called.
`pond.destroy()` then calls `ABORT_ALL`, which freezes items and calls `abortLoad()`:
```js
ABORT_ALL: function() {
Pe(n.items).forEach(function(e) {
e.freeze();
e.abortLoad();
e.abortProcessing();
});
}
```
`abortLoad()` checks `i.activeLoader`:
```js
abortLoad: function() {
i.activeLoader ? i.activeLoader.abort() : (u(Ie.INIT), l("load-abort"));
}
```
**Critical finding:** `activeLoader` is set to `null` **before** the LOAD_FILE filter chain starts (inside the loader's "load" event handler). So when `abortLoad()` is called during the LOAD_FILE chain phase, `activeLoader` is already `null` → the ELSE branch runs → status is set to INIT and `load-abort` event fires.
**The LOAD_FILE filter chain Promise continues running** because JavaScript Promises are independent of the item lifecycle. However, the item is now frozen (`i.frozen = true`), which gates the event dispatcher:
```js
l = function(e) {
if (!i.released && !i.frozen) {
f.fire.apply(f, [e].concat(n));
}
};
```
When the LOAD_FILE filter chain eventually resolves or rejects, its `.then()` or `.catch()` callbacks execute, but they call `l(...)` which is suppressed by the freeze gate. **No events reach the dispatch system after freeze.**
Therefore: **the abort mechanism does NOT cancel the LOAD_FILE filter chain, but it DOES prevent its results from dispatching events.** The chain is orphaned (still resolves/rejects in memory) but cannot cause a crash because its event callbacks are gated.
**However:** `znunoqpw`'s abort only helps when `destroyFilePondsIn` is actually called. Since HTMX never swaps the FilePond container (see above), `destroyFilePondsIn` is never called during the standard reproduction. The abort mechanism is **not exercised** in the crash scenario.
---
## Line 6878 catch reachability
### Can `load-file-error` fire during destroy, and with what value of `e.status`?
Two code paths dispatch `DID_THROW_ITEM_INVALID`, which reaches the `file-status` view writer `Wt`:
```js
// file-status view writer (minified equivalent of line 7847)
Wt = function(e) {
var t = e.root, n = e.action;
Nt(t.ref.main, n.status.main); // ← crashes if n.status is undefined
Nt(t.ref.sub, n.status.sub);
};
```
### Path A: `load-request-error` → SAFE
Dispatched by the **loader's error event** (XHR onerror). The handler PROPERLY wraps the rejection:
```js
v.on("load-request-error", function(t) {
var r = Dt(n.options.labelFileLoadError)(t);
if (t.code >= 400 && t.code < 500)
return e("DID_THROW_ITEM_INVALID", {
id: h, error: t,
status: { main: r, sub: t.code + " (" + t.body + ")" } // ✅ wrapped
});
e("DID_THROW_ITEM_LOAD_ERROR", {
id: h, error: t,
status: { main: r, sub: n.options.labelTapToRetry } // ✅ wrapped
});
});
```
Both branches create `status: { main, sub }` objects. **No crash possible from this path.**
### Path B: `load-file-error` → VULNERABLE
Dispatched by the LOAD_FILE filter chain rejection. The handler passes `t.status` **directly**, without wrapping:
```js
v.on("load-file-error", function(t) {
e("DID_THROW_ITEM_INVALID", {
id: h,
error: t.status, // t.status passed through directly
status: t.status, // ← crash if t.status is undefined
});
f({ error: t.status, file: Te(v) });
});
```
For **local files**, the LOAD_FILE chain processes the File/Blob object. The only registered LOAD_FILE filters are `FileValidateType` and `FileValidateSize`. Both plugins reject with:
```js
{ status: { main: "label...", sub: "details..." } }
```
This produces a valid `t.status = { main, sub }` — **no crash on normal local file selection.**
For **server-loaded files** (existing DB files), the `Ot` XHR loader creates `createResponse` objects with `.code` (HTTP status), **not** `.status`. When the server returns an error blob (e.g., HTML error page on 404):
1. XHR `onload` fires → `t(ot("load", status, blob, headers))` → `createResponse` object with `.code`, no `.status`
2. Loader's "load" event fires → `serverFileReference` **not set** (meta handler didn't set it for the error response)
3. `else` branch runs → LOAD_FILE filter chain receives the `createResponse` object (with `type: "load"`)
4. FileValidateType rejects because `"load"` is not a recognized MIME type → error callback → `load-file-error`
5. **But** FileValidateType rejects with `{ status: { main, sub } }` — a properly wrapped object
6. `t.status` IS `{ main, sub }` → **no crash**
**Caveat with znunoqpw:** After znunoqpw, `server.load` is configured with custom `onload`/`onerror` handlers. The custom `onerror` returns a string, which the `ut` factory routes to the loader's error callback → `load-request-error` → SAFE. The custom `onload` returns the blob directly, which bypasses the `createResponse` wrapping path for successful loads.
### Can it fire during destroy?
During `pond.destroy()` → `ABORT_ALL` → items frozen → event dispatch gated. Even if the LOAD_FILE chain completes after destroy, events are suppressed. **The crash cannot fire after `destroy()` is called.**
### Does the `.catch` handler at line 6878 fire?
The `.catch` handler on the item's before-add validation chain:
```js
.catch(function(t) {
if (!t || !t.error || !t.status) return r(!1); // guarded ✅
e("DID_THROW_ITEM_INVALID", {id: h, error: t.error, status: t.status});
})
```
Has an explicit guard and returns early on missing status. **Cannot crash.**
---
## Verdict
### HTMX race hypothesis: **REFUTED**
The hypothesis that HTMX swaps out the DOM containing the FilePond instance while a LOAD_FILE filter chain is in flight is **not supported by the evidence**:
1. No HTMX swap targets the FilePond container on the edit page — every HTMX target is a sibling or distant element
2. `htmx:targetError` fires without triggering `htmx:beforeSwap`, so even if it fires, it cannot trigger destroy
3. Even if a destroy were triggered, the freeze mechanism prevents events from reaching the dispatch system
4. The abort in `znunoqpw` is insufficient but also unnecessary — the event gate alone prevents the crash after destroy
### Actual crash cause: **INDETERMINATE (but narrowed)**
The only vulnerable code path is `load-file-error` → `DID_THROW_ITEM_INVALID` with `status: undefined` reaching the `file-status` view writer `Wt`. For the standard LOCAL file selection reproduction, I cannot identify how `status` becomes `undefined`:
- LOAD_FILE plugin rejections are properly wrapped with `{ status: { main, sub } }`
- Server-load errors go through `load-request-error` which wraps properly
- No `createResponse` objects (with `.code` instead of `.status`) enter the LOAD_FILE chain for local files
- The before-add catch handler is guarded
**However, the vulnerability is real and the crash is reproducible.** The most likely explanation is a race between an existing file's `server.load` XHR completing at the same moment a new file is added (replacing the existing one in a single-file queue). When the existing file's server.load XHR completes with an error AFTER the item has been removed but BEFORE it is frozen, the `load-file-error` event fires.
The `server.load` for the existing cover file returns a `createResponse` object (with `.code`, no `.status`). If `serverFileReference` is NOT set by the meta handler (which happens for error responses), the loader's "load" handler routes the `createResponse` to the LOAD_FILE filter chain. The FileValidateType filter rejects it because the type is `"load"` (not a MIME type), but wraps it with `{status: {main, sub}}` → safe.
However, if the existing file's server.load request is ABORTED (by the removal of the existing file when a new one is added), Firefox may fire XHR `onload` with `status: 0` or a malformed `response`. If `We(n.response, name)` throws (because `n.response` is null), the exception propagates as an unhandled error, not through the normal filter rejection path.
**This is the most likely trigger for the crash — a Firefox-specific XHR abort edge case in the server.load path for the existing cover file, racing with the addition of a new local file.**
---
## Recommended next step
Add targeted `console.log` instrumentation to `file-upload-filepond.js` at the `server.load` object to determine whether the crash correlates with an in-flight server.load request:
```js
// In buildServerConfig(), inside the load: { ... } block:
load: {
url: `${base}/load.php?id=`,
method: "GET",
onload: (response) => {
console.log("[filepond:diag] server.load onload | response type=" + (response ? response.constructor.name : "null") + " | size=" + (response ? response.size : 0));
return response;
},
onerror: (response) => {
var body = typeof response === 'string' ? response : (response && response.body ? response.body : String(response || ''));
console.error("[filepond:diag] server.load onerror | body=" + body + " | raw type=" + typeof response);
return body || "Fichier introuvable.";
},
},
```
Additionally, add a global listener to trap the crash before it propagates:
```js
document.addEventListener("FilePond:error", (e) => {
if (!e.detail || !e.detail.error) {
console.error("[filepond:diag] FilePond:error with null/undefined error detail", e.detail);
}
});
window.addEventListener("error", (e) => {
if (e.filename && e.filename.includes("filepond")) {
console.error("[filepond:diag] UNCAUGHT error from filepond", {
message: e.message,
lineno: e.lineno,
colno: e.colno,
stack: e.error ? e.error.stack : null,
});
}
});
```
Then reproduce with `just dev` in Firefox. If the diagnostic logs show `server.load onload` firing immediately before the crash, the race theory is confirmed and the fix is to replace `server.load` with a custom `fetch`-based function (Option B from the crash analysis) that never routes server responses through the LOAD_FILE filter chain.
+166
View File
@@ -0,0 +1,166 @@
# LDAP Authentication for XAMXAM Admin
> Merged from `LDAP_AUTH_PLAN.md` and `LDAP_SPEC.md`.
> **Status: not implemented.** Network access to the LDAP server is an unresolved blocker.
---
## Current state
Two-layer authentication guards the `/admin/` area:
| Layer | Mechanism | Where |
|-------|-----------|-------|
| 1 (nginx) | `auth_basic` against `/etc/nginx/.htpasswd-xamxam` | `nginx/xamxam.conf` |
| 2 (PHP) | `AdminAuth` — bcrypt hash in `site_settings.admin_password_hash` | `app/src/AdminAuth.php` + login/account handlers |
Layer 1 controls the browser's Basic Auth dialog; layer 2 provides a PHP session
gate. When both share the same password the user is authenticated transparently
(nginx passes `PHP_AUTH_PW`, `AdminAuth` verifies it against the DB hash).
## Goal
Replace both layers with LDAP so staff use their existing org credentials and
account lifecycle is handled centrally.
---
## Required information from IT
| # | Item | Example / format |
|---|------|------------------|
| 1 | LDAP server URL | `ldaps://ldap.erg.be:636` or `ldap://ldap.erg.be:389` |
| 2 | Base DN | `dc=erg,dc=be` |
| 3 | Bind DN (read-only service/search account) | `cn=xamxam-svc,ou=services,dc=erg,dc=be` |
| 4 | Bind password | (secret — read-only is sufficient) |
| 5 | User search filter | `(&(uid=%s)(memberOf=cn=xamxam-admins,ou=groups,dc=erg,dc=be))` |
| 6 | Group membership mechanism | `memberOf` (AD) **or** `member`/`uniqueMember` (OpenLDAP) |
| 7 | Username attribute | `uid` (OpenLDAP) or `sAMAccountName` (AD) |
| 8 | TLS certificate | self-signed? provide CA PEM. Otherwise confirm publicly-trusted cert |
| 9 | Admin group DN/CN | `cn=xamxam-admins,ou=groups,dc=erg,dc=be` (or decide on a name) |
## Network prerequisite (blocker)
XAMXAM's VM may not have direct TCP access to the LDAP server. Confirm before
writing any code:
```bash
nc -zv <ldap-host> 636 # LDAPS (preferred)
nc -zv <ldap-host> 389 # plain LDAP (fallback, trusted LAN only)
```
---
## Chosen approach: PHP-only LDAP (no nginx layer)
**Decision:** replace both layers with a single LDAP-backed PHP login. No
nginx module compilation; the existing `AdminAuth` session architecture stays
intact — only the credential-verification back-end changes.
This is "Option C" below (the earlier "Option A" nginx-ldap-auth daemon was
evaluated but not chosen).
---
## Architecture options (evaluated)
### Option A — `nginx-ldap-auth` daemon
- Drop-in `auth_basic` replacement using nginx `auth_request`; a Python daemon
at `127.0.0.1:8888` binds to LDAP and returns 200/403.
- Keeps the nginx-level gate; needs `python3-ldap` + the daemon.
### Option B — `ngx_http_auth_ldap_module`
- Native nginx module, requires recompiling nginx. Simpler config, less flexible.
### Option C — PHP-only LDAP (chosen)
- Remove nginx auth entirely; `AdminAuth::requireLogin()` does `ldap_bind()` +
group check directly in PHP. Simpler nginx config, no separate daemon.
---
## Implementation plan (Option C)
### Phase 1 — Server preparation
- [ ] Confirm network access (see blocker).
- [ ] `sudo apt install php8.4-ldap` + `sudo systemctl restart php8.4-fpm`; verify `php -m | grep ldap`.
- [ ] Store connection params in `site_settings` or a server-side env file (**never in repo**):
`ldap_host`, `ldap_port`, `ldap_bind_dn`, `ldap_bind_password`,
`ldap_base_dn`, `ldap_user_attr`, `ldap_group_dn` (optional).
### Phase 2 — New `LdapAuth` class
`app/src/LdapAuth.php` → `LdapAuth::verify(string $username, string $password): bool`:
1. Load params from `Database::getSetting()`.
2. `ldap_connect($host, $port)`; set `LDAP_OPT_PROTOCOL_VERSION=3`, `LDAP_OPT_REFERRALS=0`, `LDAP_OPT_NETWORK_TIMEOUT=3` (fail fast).
3. Service-account bind `ldap_bind($conn, $bind_dn, $bind_password)`.
4. Search user: `ldap_search($conn, $base_dn, "($attr=$username)", ['dn'])`, extract user DN.
5. Optional group check: verify membership against `ldap_group_dn`.
6. User bind `ldap_bind($conn, $user_dn, $password)` — the actual credential check.
7. `ldap_unbind($conn)`; return `true`/`false`.
Error handling: catch `ldap_error()`/`ldap_errno()` on each step; log failures
(never expose LDAP error strings to the browser); **fail closed** when the LDAP
server is unreachable.
### Phase 3 — Modify `AdminAuth`
| Location | Change |
|---|---|
| `AdminAuth::login()` | Replace `password_verify()` with `LdapAuth::verify($username, $password)` |
| `requireLogin()` nginx passthrough (`$_SERVER['PHP_AUTH_PW']`) | Remove (nginx `auth_basic` gone) |
| `getStoredHash()` / `setPasswordHash()` / `removePasswordHash()` | Retire |
Session logic (`SESSION_KEY`, `session_regenerate_id`, cookie hardening,
`logout()`) is unchanged — auth-method-agnostic. The login form gains a
`username` field; the password-change page is retired.
### Phase 4 — Modify the login form
Add `<input name="username">` before password; remove "change password" link;
POST handler calls `AdminAuth::login($username, $password)`.
### Phase 5 — Remove nginx `auth_basic`
In `nginx/xamxam.conf` (inside `location ^~ /admin/`), remove `auth_basic` and
`auth_basic_user_file`. Keep the `limit_req zone=admin` rate limit. Update
`scripts/deploy-server.sh` / `manage-admin-users.sh`; `sudo rm /etc/nginx/.htpasswd-xamxam`.
### Phase 6 — Retire password-management UI
Remove/repurpose `account.php` handlers + templates; remove the "Compte" nav link;
optionally clear `admin_password_hash` from `site_settings`.
### Phase 7 — Testing
- [ ] LDAP reachable from VM (Phase 1 smoke test)
- [ ] Valid staff credentials → session created, redirected to `/admin/`
- [ ] Invalid password / unknown username → denied (same error message — no enum)
- [ ] LDAP unreachable → "service unavailable", not a PHP fatal
- [ ] Group check: non-member staff → denied
- [ ] Session expiry/logout → redirected to login
- [ ] 20+ rapid logins → nginx rate limit (429)
- [ ] `/etc/nginx/.htpasswd-xamxam` removed on server
---
## What does NOT change
- PHP session layer (`AdminAuth::startSession`, `isAuthenticated`, `logout`, cookie params)
- CSRF protection on all action handlers
- nginx rate-limiting zone for `/admin/` and all other nginx security rules
- `AdminAuth` remains for **session management** (persistence, logout, CSRF, audit identity);
LDAP handles **authentication** only
## Security notes
- **Use LDAPS (port 636) exclusively** — plain LDAP transmits passwords in cleartext.
- **Service account must be read-only.**
- **Never store the service-account password in the repo.** Use `Database::setSetting()` or a server env var.
- **Never log user/service passwords.**
- **Fail closed** on connect/bind failure.
- **Escape the username** per RFC 4515 (`ldap_escape(..., LDAP_ESCAPE_FILTER)`) to prevent LDAP injection.
+1 -1
View File
@@ -18,7 +18,7 @@ Current security posture for XAMXAM.
`Path=/admin`; regenerated on login. `Path=/admin`; regenerated on login.
- nginx `auth_basic` layer has been removed; the PHP session layer is the only - nginx `auth_basic` layer has been removed; the PHP session layer is the only
gate. (LDAP-based login is a proposed future enhancement — see gate. (LDAP-based login is a proposed future enhancement — see
`LDAP_AUTH_PLAN.md` / `LDAP_SPEC.md`. It is **not** implemented.) [`ldap.md`](ldap.md). It is **not** implemented.)
## Transport & headers ## Transport & headers
+31
View File
@@ -168,3 +168,34 @@ L'étudiant·e peut, à tout moment, décider de restreindre son propre choix. I
---
# Annexes : questions & remarques client·e (issues de SPECS.md)
## Accueil
- Ordre des TFE sur la page d'accueil : dérouler par année (plus récents en haut), ordre de chaque année aléatoire.
- Disposer des PDF et notes d'intention des dernières années ; nomenclature particulière demandée ?
- Export de la maquette du site (jpg) pour produire les textes en adéquation.
## admin
- Décision : ne pas rendre les TFE visibles vers l'extérieur pour l'instant.
- Option « interne » comme défaut sur une majorité des TFE.
- Pas disponible : PDF + note d'intention uniquement accessibles physiquement à l'erg (par IP) ou via login.
## Formulaire de dépôt
- Les étudiant·es préparent une image au bon format (taille à préciser).
- Sur la page du formulaire : ajouter l'explication et le contexte des choix.
- Un TFE déposé n'est pas publié directement : il arrive en base, quelqu'un clique « publier » dans le backoffice après la défense (et selon le jury).
- L'option « libre » n'existe pas encore cette année ; système de toggle pour activer les options du formulaire (seulement « interdit » + « interne » pour l'instant, « libre » l'an prochain).
- Case « contact » accompagnée d'une case à cocher « Je veux que mon contact soit accessible à toustes » ; selon la réponse, le contact apparaît ou non sur la page du TFE.
## Base de données
- Ajouter une catégorie « objet » pour différencier TFE / FRART / thèses (pour l'instant simple tag en back-office).
- Quelle(s) fonte(s) utilisée(s) sur le site ?
- Envoi d'un export de la maquette (jpg) pour rafraîchir la mémoire et produire les textes.